aboutsummaryrefslogtreecommitdiffstats
path: root/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'plugins')
-rw-r--r--plugins/adhoc/adhoc.lib.lua10
-rw-r--r--plugins/adhoc/mod_adhoc.lua14
-rw-r--r--plugins/mod_admin_adhoc.lua114
-rw-r--r--plugins/mod_admin_shell.lua1870
-rw-r--r--plugins/mod_admin_socket.lua73
-rw-r--r--plugins/mod_admin_telnet.lua1534
-rw-r--r--plugins/mod_announce.lua1
-rw-r--r--plugins/mod_auth_anonymous.lua10
-rw-r--r--plugins/mod_auth_cyrus.lua85
-rw-r--r--plugins/mod_auth_internal_hashed.lua16
-rw-r--r--plugins/mod_auth_ldap.lua154
-rw-r--r--plugins/mod_authz_internal.lua59
-rw-r--r--plugins/mod_blocklist.lua5
-rw-r--r--plugins/mod_bookmarks.lua481
-rw-r--r--plugins/mod_bosh.lua138
-rw-r--r--plugins/mod_c2s.lua175
-rw-r--r--plugins/mod_carbons.lua95
-rw-r--r--plugins/mod_component.lua27
-rw-r--r--plugins/mod_cron.lua64
-rw-r--r--plugins/mod_csi.lua14
-rw-r--r--plugins/mod_csi_simple.lua300
-rw-r--r--plugins/mod_dialback.lua12
-rw-r--r--plugins/mod_disco.lua24
-rw-r--r--plugins/mod_external_services.lua243
-rw-r--r--plugins/mod_groups.lua6
-rw-r--r--plugins/mod_http.lua182
-rw-r--r--plugins/mod_http_errors.lua73
-rw-r--r--plugins/mod_http_file_share.lua599
-rw-r--r--plugins/mod_http_files.lua175
-rw-r--r--plugins/mod_http_openmetrics.lua60
-rw-r--r--plugins/mod_invites.lua340
-rw-r--r--plugins/mod_invites_adhoc.lua126
-rw-r--r--plugins/mod_invites_register.lua160
-rw-r--r--plugins/mod_lastactivity.lua2
-rw-r--r--plugins/mod_legacyauth.lua4
-rw-r--r--plugins/mod_limits.lua62
-rw-r--r--plugins/mod_mam/mod_mam.lua315
-rw-r--r--plugins/mod_message.lua15
-rw-r--r--plugins/mod_mimicking.lua86
-rw-r--r--plugins/mod_muc_mam.lua169
-rw-r--r--plugins/mod_net_multiplex.lua43
-rw-r--r--plugins/mod_offline.lua12
-rw-r--r--plugins/mod_pep.lua52
-rw-r--r--plugins/mod_pep_simple.lua6
-rw-r--r--plugins/mod_ping.lua18
-rw-r--r--plugins/mod_posix.lua80
-rw-r--r--plugins/mod_presence.lua37
-rw-r--r--plugins/mod_proxy65.lua7
-rw-r--r--plugins/mod_pubsub/mod_pubsub.lua57
-rw-r--r--plugins/mod_pubsub/pubsub.lib.lua119
-rw-r--r--plugins/mod_register.lua1
-rw-r--r--plugins/mod_register_ibr.lua51
-rw-r--r--plugins/mod_register_limits.lua59
-rw-r--r--plugins/mod_roster.lua6
-rw-r--r--plugins/mod_s2s.lua (renamed from plugins/mod_s2s/mod_s2s.lua)595
-rw-r--r--plugins/mod_s2s/s2sout.lib.lua349
-rw-r--r--plugins/mod_s2s_auth_certs.lua8
-rw-r--r--plugins/mod_s2s_bidi.lua40
-rw-r--r--plugins/mod_saslauth.lua93
-rw-r--r--plugins/mod_scansion_record.lua8
-rw-r--r--plugins/mod_server_contact_info.lua5
-rw-r--r--plugins/mod_smacks.lua728
-rw-r--r--plugins/mod_stanza_debug.lua5
-rw-r--r--plugins/mod_storage_internal.lua161
-rw-r--r--plugins/mod_storage_memory.lua108
-rw-r--r--plugins/mod_storage_sql.lua307
-rw-r--r--plugins/mod_storage_xep0227.lua642
-rw-r--r--plugins/mod_tls.lua56
-rw-r--r--plugins/mod_tokenauth.lua82
-rw-r--r--plugins/mod_tombstones.lua79
-rw-r--r--plugins/mod_turn_external.lua28
-rw-r--r--plugins/mod_uptime.lua4
-rw-r--r--plugins/mod_user_account_management.lua3
-rw-r--r--plugins/mod_vcard.lua2
-rw-r--r--plugins/mod_vcard_legacy.lua77
-rw-r--r--plugins/mod_websocket.lua95
-rw-r--r--plugins/muc/hats.lib.lua26
-rw-r--r--plugins/muc/history.lib.lua35
-rw-r--r--plugins/muc/language.lib.lua1
-rw-r--r--plugins/muc/lock.lib.lua2
-rw-r--r--plugins/muc/members_only.lib.lua7
-rw-r--r--plugins/muc/mod_muc.lua107
-rw-r--r--plugins/muc/muc.lib.lua437
-rw-r--r--plugins/muc/name.lib.lua4
-rw-r--r--plugins/muc/occupant_id.lib.lua76
-rw-r--r--plugins/muc/password.lib.lua5
-rw-r--r--plugins/muc/presence_broadcast.lib.lua83
-rw-r--r--plugins/muc/register.lib.lua62
-rw-r--r--plugins/muc/subject.lib.lua6
-rw-r--r--plugins/muc/util.lib.lua18
90 files changed, 9506 insertions, 3278 deletions
diff --git a/plugins/adhoc/adhoc.lib.lua b/plugins/adhoc/adhoc.lib.lua
index 0b910299..4cf6911d 100644
--- a/plugins/adhoc/adhoc.lib.lua
+++ b/plugins/adhoc/adhoc.lib.lua
@@ -21,7 +21,13 @@ local function _cmdtag(desc, status, sessionid, action)
end
function _M.new(name, node, handler, permission)
- return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = (permission or "user") };
+ if not permission then
+ error "adhoc.new() expects a permission argument, none given"
+ end
+ if permission == "user" then
+ error "the permission mode 'user' has been renamed 'any', please update your code"
+ end
+ return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = permission };
end
function _M.handle_cmd(command, origin, stanza)
@@ -45,7 +51,7 @@ function _M.handle_cmd(command, origin, stanza)
cmdreply = command:cmdtag("canceled", sessionid);
elseif data.status == "error" then
states[sessionid] = nil;
- local reply = st.error_reply(stanza, data.error.type, data.error.condition, data.error.message);
+ local reply = st.error_reply(stanza, data.error);
origin.send(reply);
return true;
else
diff --git a/plugins/adhoc/mod_adhoc.lua b/plugins/adhoc/mod_adhoc.lua
index bf1775b4..23846270 100644
--- a/plugins/adhoc/mod_adhoc.lua
+++ b/plugins/adhoc/mod_adhoc.lua
@@ -8,7 +8,7 @@
local it = require "util.iterators";
local st = require "util.stanza";
local is_admin = require "core.usermanager".is_admin;
-local jid_split = require "util.jid".split;
+local jid_host = require "util.jid".host;
local adhoc_handle_cmd = module:require "adhoc".handle_cmd;
local xmlns_cmd = "http://jabber.org/protocol/commands";
local commands = {};
@@ -21,12 +21,12 @@ module:hook("host-disco-info-node", function (event)
local from = stanza.attr.from;
local privileged = is_admin(from, stanza.attr.to);
local global_admin = is_admin(from);
- local username, hostname = jid_split(from);
+ local hostname = jid_host(from);
local command = commands[node];
if (command.permission == "admin" and privileged)
or (command.permission == "global_admin" and global_admin)
or (command.permission == "local_user" and hostname == module.host)
- or (command.permission == "user") then
+ or (command.permission == "any") then
reply:tag("identity", { name = command.name,
category = "automation", type = "command-node" }):up();
reply:tag("feature", { var = xmlns_cmd }):up();
@@ -52,12 +52,12 @@ module:hook("host-disco-items-node", function (event)
local from = stanza.attr.from;
local admin = is_admin(from, stanza.attr.to);
local global_admin = is_admin(from);
- local username, hostname = jid_split(from);
+ local hostname = jid_host(from);
for node, command in it.sorted_pairs(commands) do
if (command.permission == "admin" and admin)
or (command.permission == "global_admin" and global_admin)
or (command.permission == "local_user" and hostname == module.host)
- or (command.permission == "user") then
+ or (command.permission == "any") then
reply:tag("item", { name = command.name,
node = node, jid = module:get_host() });
reply:up();
@@ -74,7 +74,7 @@ module:hook("iq-set/host/"..xmlns_cmd..":command", function (event)
local from = stanza.attr.from;
local admin = is_admin(from, stanza.attr.to);
local global_admin = is_admin(from);
- local username, hostname = jid_split(from);
+ local hostname = jid_host(from);
if (command.permission == "admin" and not admin)
or (command.permission == "global_admin" and not global_admin)
or (command.permission == "local_user" and hostname ~= module.host) then
@@ -91,6 +91,8 @@ end, 500);
local function adhoc_added(event)
local item = event.item;
+ -- Dang this was noicy
+ module:log("debug", "Command added by mod_%s: %q, %q", item._provided_by or "<unknown module>", item.name, item.node);
commands[item.node] = item;
end
diff --git a/plugins/mod_admin_adhoc.lua b/plugins/mod_admin_adhoc.lua
index 37e77ab0..d0b0d452 100644
--- a/plugins/mod_admin_adhoc.lua
+++ b/plugins/mod_admin_adhoc.lua
@@ -18,7 +18,6 @@ local keys = require "util.iterators".keys;
local usermanager_user_exists = require "core.usermanager".user_exists;
local usermanager_create_user = require "core.usermanager".create_user;
local usermanager_delete_user = require "core.usermanager".delete_user;
-local usermanager_get_password = require "core.usermanager".get_password;
local usermanager_set_password = require "core.usermanager".set_password;
local hostmanager_activate = require "core.hostmanager".activate;
local hostmanager_deactivate = require "core.hostmanager".deactivate;
@@ -55,11 +54,11 @@ local add_user_layout = dataforms_new{
{ name = "password-verify", type = "text-private", label = "Retype password" };
};
-local add_user_command_handler = adhoc_simple(add_user_layout, function(fields, err)
+local add_user_command_handler = adhoc_simple(add_user_layout, function(fields, err, data)
if err then
return generate_error_message(err);
end
- local username, host, resource = jid.split(fields.accountjid);
+ local username, host = jid.split(fields.accountjid);
if module_host ~= host then
return { status = "completed", error = { message = "Trying to add a user on " .. host .. " but command was sent to " .. module_host}};
end
@@ -68,7 +67,7 @@ local add_user_command_handler = adhoc_simple(add_user_layout, function(fields,
return { status = "completed", error = { message = "Account already exists" } };
else
if usermanager_create_user(username, fields.password, host) then
- module:log("info", "Created new account %s@%s", username, host);
+ module:log("info", "Created new account %s@%s by %s", username, host, jid.bare(data.from));
return { status = "completed", info = "Account successfully created" };
else
return { status = "completed", error = { message = "Failed to write data to disk" } };
@@ -90,11 +89,11 @@ local change_user_password_layout = dataforms_new{
{ name = "password", type = "text-private", required = true, label = "The password for this account" };
};
-local change_user_password_command_handler = adhoc_simple(change_user_password_layout, function(fields, err)
+local change_user_password_command_handler = adhoc_simple(change_user_password_layout, function(fields, err, data)
if err then
return generate_error_message(err);
end
- local username, host, resource = jid.split(fields.accountjid);
+ local username, host = jid.split(fields.accountjid);
if module_host ~= host then
return {
status = "completed",
@@ -104,6 +103,7 @@ local change_user_password_command_handler = adhoc_simple(change_user_password_l
};
end
if usermanager_user_exists(username, host) and usermanager_set_password(username, fields.password, host, nil) then
+ module:log("info", "Password of account %s@%s changed by %s", username, host, jid.bare(data.from));
return { status = "completed", info = "Password successfully changed" };
else
return { status = "completed", error = { message = "User does not exist" } };
@@ -112,6 +112,7 @@ end);
-- Reloading the config
local function config_reload_handler(self, data, state)
+ module:log("info", "%s reloads the config", jid.bare(data.from));
local ok, err = prosody.reload_config();
if ok then
return { status = "completed", info = "Configuration reloaded (modules may need to be reloaded for this to have an effect)" };
@@ -129,19 +130,19 @@ local delete_user_layout = dataforms_new{
{ name = "accountjids", type = "jid-multi", required = true, label = "The Jabber ID(s) to delete" };
};
-local delete_user_command_handler = adhoc_simple(delete_user_layout, function(fields, err)
+local delete_user_command_handler = adhoc_simple(delete_user_layout, function(fields, err, data)
if err then
return generate_error_message(err);
end
local failed = {};
local succeeded = {};
for _, aJID in ipairs(fields.accountjids) do
- local username, host, resource = jid.split(aJID);
+ local username, host = jid.split(aJID);
if (host == module_host) and usermanager_user_exists(username, host) and usermanager_delete_user(username, host) then
- module:log("debug", "User %s has been deleted", aJID);
+ module:log("info", "User %s has been deleted by %s", aJID, jid.bare(data.from));
succeeded[#succeeded+1] = aJID;
else
- module:log("debug", "Tried to delete non-existant user %s", aJID);
+ module:log("debug", "Tried to delete non-existent user %s", aJID);
failed[#failed+1] = aJID;
end
end
@@ -180,7 +181,7 @@ local end_user_session_handler = adhoc_simple(end_user_session_layout, function(
local failed = {};
local succeeded = {};
for _, aJID in ipairs(fields.accountjids) do
- local username, host, resource = jid.split(aJID);
+ local username, host = jid.split(aJID);
if (host == module_host) and usermanager_user_exists(username, host) and disconnect_user(aJID) then
succeeded[#succeeded+1] = aJID;
else
@@ -193,39 +194,6 @@ local end_user_session_handler = adhoc_simple(end_user_session_layout, function(
"The following accounts could not be disconnected:\n"..t_concat(failed, "\n") or "") };
end);
--- Getting a user's password
-local get_user_password_layout = dataforms_new{
- title = "Getting User's Password";
- instructions = "Fill out this form to get a user's password.";
-
- { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
- { name = "accountjid", type = "jid-single", required = true, label = "The Jabber ID for which to retrieve the password" };
-};
-
-local get_user_password_result_layout = dataforms_new{
- { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
- { name = "accountjid", type = "jid-single", label = "JID" };
- { name = "password", type = "text-single", label = "Password" };
-};
-
-local get_user_password_handler = adhoc_simple(get_user_password_layout, function(fields, err)
- if err then
- return generate_error_message(err);
- end
- local user, host, resource = jid.split(fields.accountjid);
- local accountjid;
- local password;
- if host ~= module_host then
- return { status = "completed", error = { message = "Tried to get password for a user on " .. host .. " but command was sent to " .. module_host } };
- elseif usermanager_user_exists(user, host) then
- accountjid = fields.accountjid;
- password = usermanager_get_password(user, host);
- else
- return { status = "completed", error = { message = "User does not exist" } };
- end
- return { status = "completed", result = { layout = get_user_password_result_layout, values = {accountjid = accountjid, password = password} } };
-end);
-
-- Getting a user's roster
local get_user_roster_layout = dataforms_new{
{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
@@ -243,7 +211,7 @@ local get_user_roster_handler = adhoc_simple(get_user_roster_layout, function(fi
return generate_error_message(err);
end
- local user, host, resource = jid.split(fields.accountjid);
+ local user, host = jid.split(fields.accountjid);
if host ~= module_host then
return { status = "completed", error = { message = "Tried to get roster for a user on " .. host .. " but command was sent to " .. module_host } };
elseif not usermanager_user_exists(user, host) then
@@ -286,7 +254,7 @@ local get_user_stats_layout = dataforms_new{
local get_user_stats_result_layout = dataforms_new{
{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
{ name = "ipaddresses", type = "text-multi", label = "IP Addresses" };
- { name = "rostersize", type = "text-single", label = "Roster size" };
+ { name = "rostersize", type = "text-single", label = "Roster size", datatype = "xs:integer" };
{ name = "onlineresources", type = "text-multi", label = "Online Resources" };
};
@@ -314,7 +282,7 @@ local get_user_stats_handler = adhoc_simple(get_user_stats_layout, function(fiel
resources = resources .. "\n" .. resource;
IPs = IPs .. "\n" .. session.ip;
end
- return { status = "completed", result = {layout = get_user_stats_result_layout, values = {ipaddresses = IPs, rostersize = tostring(rostersize),
+ return { status = "completed", result = {layout = get_user_stats_result_layout, values = {ipaddresses = IPs, rostersize = rostersize,
onlineresources = resources}} };
end);
@@ -373,10 +341,10 @@ end);
local list_s2s_this_result = dataforms_new {
title = "List of S2S connections on this host";
- { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/s2s#list" };
- { name = "sessions", type = "text-multi", label = "Connections:" };
- { name = "num_in", type = "text-single", label = "#incoming connections:" };
- { name = "num_out", type = "text-single", label = "#outgoing connections:" };
+ { name = "FORM_TYPE"; type = "hidden"; value = "http://prosody.im/protocol/s2s#list" };
+ { name = "sessions"; type = "text-multi"; label = "Connections:" };
+ { name = "num_in"; type = "text-single"; label = "#incoming connections:"; datatype = "xs:integer" };
+ { name = "num_out"; type = "text-single"; label = "#outgoing connections:"; datatype = "xs:integer" };
};
local function session_flags(session, line)
@@ -392,6 +360,12 @@ local function session_flags(session, line)
if session.cert_identity_status == "valid" then
flags[#flags+1] = "authenticated";
end
+ if session.dialback_key then
+ flags[#flags+1] = "dialback";
+ end
+ if session.external_auth then
+ flags[#flags+1] = "SASL";
+ end
if session.secure then
flags[#flags+1] = "encrypted";
end
@@ -404,6 +378,12 @@ local function session_flags(session, line)
if session.ip and session.ip:match(":") then
flags[#flags+1] = "IPv6";
end
+ if session.incoming and session.outgoing then
+ flags[#flags+1] = "bidi";
+ elseif session.is_bidi or session.bidi_session then
+ flags[#flags+1] = "bidi";
+ end
+
line[#line+1] = "("..t_concat(flags, ", ")..")";
return t_concat(line, " ");
@@ -443,8 +423,8 @@ local function list_s2s_this_handler(self, data, state)
return { status = "completed", result = { layout = list_s2s_this_result; values = {
sessions = t_concat(s2s_list, "\n"),
- num_in = tostring(count_in),
- num_out = tostring(count_out)
+ num_in = count_in,
+ num_out = count_out
} } };
end
@@ -495,7 +475,7 @@ local globally_load_module_layout = dataforms_new {
{ name = "module", type = "text-single", required = true, label = "Module to globally load:"};
};
-local globally_load_module_handler = adhoc_simple(globally_load_module_layout, function(fields, err)
+local globally_load_module_handler = adhoc_simple(globally_load_module_layout, function(fields, err, data)
local ok_list, err_list = {}, {};
if err then
@@ -511,6 +491,7 @@ local globally_load_module_handler = adhoc_simple(globally_load_module_layout, f
-- Is this a global module?
if modulemanager.is_loaded("*", fields.module) and not modulemanager.is_loaded(module_host, fields.module) then
+ module:log("info", "mod_%s loaded by %s", fields.module, jid.bare(data.from));
return { status = "completed", info = 'Global module '..fields.module..' loaded.' };
end
@@ -526,6 +507,7 @@ local globally_load_module_handler = adhoc_simple(globally_load_module_layout, f
end
end
+ module:log("info", "mod_%s loaded by %s", fields.module, jid.bare(data.from));
local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully loaded onto the hosts:\n"..t_concat(ok_list, "\n")) or "")
.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
(#err_list > 0 and ("Failed to load the module "..fields.module.." onto the hosts:\n"..t_concat(err_list, "\n")) or "");
@@ -543,7 +525,7 @@ local reload_modules_layout = dataforms_new {
local reload_modules_handler = adhoc_initial(reload_modules_layout, function()
return { modules = array.collect(keys(hosts[module_host].modules)):sort() };
-end, function(fields, err)
+end, function(fields, err, data)
if err then
return generate_error_message(err);
end
@@ -556,6 +538,7 @@ end, function(fields, err)
err_list[#err_list + 1] = module .. "(Error: " .. tostring(err) .. ")";
end
end
+ module:log("info", "mod_%s reloaded by %s", fields.module, jid.bare(data.from));
local info = (#ok_list > 0 and ("The following modules were successfully reloaded on host "..module_host..":\n"..t_concat(ok_list, "\n")) or "")
.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
(#err_list > 0 and ("Failed to reload the following modules on host "..module_host..":\n"..t_concat(err_list, "\n")) or "");
@@ -578,7 +561,7 @@ local globally_reload_module_handler = adhoc_initial(globally_reload_module_layo
end
loaded_modules = array(set.new(loaded_modules):items()):sort();
return { module = loaded_modules };
-end, function(fields, err)
+end, function(fields, err, data)
local is_global = false;
if err then
@@ -613,6 +596,7 @@ end, function(fields, err)
end
end
+ module:log("info", "mod_%s reloaded by %s", fields.module, jid.bare(data.from));
local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully reloaded on the hosts:\n"..t_concat(ok_list, "\n")) or "")
.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
(#err_list > 0 and ("Failed to reload the module "..fields.module.." on the hosts:\n"..t_concat(err_list, "\n")) or "");
@@ -662,11 +646,13 @@ local shut_down_service_layout = dataforms_new{
{ name = "announcement", type = "text-multi", label = "Announcement" };
};
-local shut_down_service_handler = adhoc_simple(shut_down_service_layout, function(fields, err)
+local shut_down_service_handler = adhoc_simple(shut_down_service_layout, function(fields, err, data)
if err then
return generate_error_message(err);
end
+ module:log("info", "Server being shut down by %s", jid.bare(data.from));
+
if fields.announcement and #fields.announcement > 0 then
local message = st.message({type = "headline"}, fields.announcement):up()
:tag("subject"):text("Server is shutting down");
@@ -689,7 +675,7 @@ local unload_modules_layout = dataforms_new {
local unload_modules_handler = adhoc_initial(unload_modules_layout, function()
return { modules = array.collect(keys(hosts[module_host].modules)):sort() };
-end, function(fields, err)
+end, function(fields, err, data)
if err then
return generate_error_message(err);
end
@@ -702,6 +688,7 @@ end, function(fields, err)
err_list[#err_list + 1] = module .. "(Error: " .. tostring(err) .. ")";
end
end
+ module:log("info", "mod_%s unloaded by %s", fields.module, jid.bare(data.from));
local info = (#ok_list > 0 and ("The following modules were successfully unloaded on host "..module_host..":\n"..t_concat(ok_list, "\n")) or "")
.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
(#err_list > 0 and ("Failed to unload the following modules on host "..module_host..":\n"..t_concat(err_list, "\n")) or "");
@@ -724,7 +711,7 @@ local globally_unload_module_handler = adhoc_initial(globally_unload_module_layo
end
loaded_modules = array(set.new(loaded_modules):items()):sort();
return { module = loaded_modules };
-end, function(fields, err)
+end, function(fields, err, data)
local is_global = false;
if err then
return generate_error_message(err);
@@ -758,6 +745,7 @@ end, function(fields, err)
end
end
+ module:log("info", "mod_%s globally unloaded by %s", fields.module, jid.bare(data.from));
local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully unloaded on the hosts:\n"..t_concat(ok_list, "\n")) or "")
.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
(#err_list > 0 and ("Failed to unload the module "..fields.module.." on the hosts:\n"..t_concat(err_list, "\n")) or "");
@@ -773,13 +761,14 @@ local activate_host_layout = dataforms_new {
{ name = "host", type = "text-single", required = true, label = "Host:"};
};
-local activate_host_handler = adhoc_simple(activate_host_layout, function(fields, err)
+local activate_host_handler = adhoc_simple(activate_host_layout, function(fields, err, data)
if err then
return generate_error_message(err);
end
local ok, err = hostmanager_activate(fields.host);
if ok then
+ module:log("info", "Host '%s' activated by %s", fields.host, jid.bare(data.from));
return { status = "completed", info = fields.host .. " activated" };
else
return { status = "canceled", error = err }
@@ -795,13 +784,14 @@ local deactivate_host_layout = dataforms_new {
{ name = "host", type = "text-single", required = true, label = "Host:"};
};
-local deactivate_host_handler = adhoc_simple(deactivate_host_layout, function(fields, err)
+local deactivate_host_handler = adhoc_simple(deactivate_host_layout, function(fields, err, data)
if err then
return generate_error_message(err);
end
local ok, err = hostmanager_deactivate(fields.host);
if ok then
+ module:log("info", "Host '%s' deactivated by %s", fields.host, jid.bare(data.from));
return { status = "completed", info = fields.host .. " deactivated" };
else
return { status = "canceled", error = err }
@@ -815,7 +805,6 @@ local change_user_password_desc = adhoc_new("Change User Password", "http://jabb
local config_reload_desc = adhoc_new("Reload configuration", "http://prosody.im/protocol/config#reload", config_reload_handler, "global_admin");
local delete_user_desc = adhoc_new("Delete User", "http://jabber.org/protocol/admin#delete-user", delete_user_command_handler, "admin");
local end_user_session_desc = adhoc_new("End User Session", "http://jabber.org/protocol/admin#end-user-session", end_user_session_handler, "admin");
-local get_user_password_desc = adhoc_new("Get User Password", "http://jabber.org/protocol/admin#get-user-password", get_user_password_handler, "admin");
local get_user_roster_desc = adhoc_new("Get User Roster","http://jabber.org/protocol/admin#get-user-roster", get_user_roster_handler, "admin");
local get_user_stats_desc = adhoc_new("Get User Statistics","http://jabber.org/protocol/admin#user-stats", get_user_stats_handler, "admin");
local get_online_users_desc = adhoc_new("Get List of Online Users", "http://jabber.org/protocol/admin#get-online-users-list", get_online_users_command_handler, "admin");
@@ -836,7 +825,6 @@ module:provides("adhoc", change_user_password_desc);
module:provides("adhoc", config_reload_desc);
module:provides("adhoc", delete_user_desc);
module:provides("adhoc", end_user_session_desc);
-module:provides("adhoc", get_user_password_desc);
module:provides("adhoc", get_user_roster_desc);
module:provides("adhoc", get_user_stats_desc);
module:provides("adhoc", get_online_users_desc);
diff --git a/plugins/mod_admin_shell.lua b/plugins/mod_admin_shell.lua
new file mode 100644
index 00000000..1f860370
--- /dev/null
+++ b/plugins/mod_admin_shell.lua
@@ -0,0 +1,1870 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- luacheck: ignore 212/self
+
+module:set_global();
+module:depends("admin_socket");
+
+local hostmanager = require "core.hostmanager";
+local modulemanager = require "core.modulemanager";
+local s2smanager = require "core.s2smanager";
+local portmanager = require "core.portmanager";
+local helpers = require "util.helpers";
+local server = require "net.server";
+local st = require "util.stanza";
+
+local _G = _G;
+
+local prosody = _G.prosody;
+
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
+local iterators = require "util.iterators";
+local keys, values = iterators.keys, iterators.values;
+local jid_bare, jid_split, jid_join = import("util.jid", "bare", "prepped_split", "join");
+local set, array = require "util.set", require "util.array";
+local cert_verify_identity = require "util.x509".verify_identity;
+local envload = require "util.envload".envload;
+local envloadfile = require "util.envload".envloadfile;
+local has_pposix, pposix = pcall(require, "util.pposix");
+local async = require "util.async";
+local serialization = require "util.serialization";
+local serialize_config = serialization.new ({ fatal = false, unquoted = true});
+local time = require "util.time";
+local promise = require "util.promise";
+
+local t_insert = table.insert;
+local t_concat = table.concat;
+
+local format_number = require "util.human.units".format;
+local format_table = require "util.human.io".table;
+
+local function capitalize(s)
+ if not s then return end
+ return (s:gsub("^%a", string.upper):gsub("_", " "));
+end
+
+local function pre(prefix, str, alt)
+ if (str or "") == "" then return alt or ""; end
+ return prefix .. str;
+end
+
+local function suf(str, suffix, alt)
+ if (str or "") == "" then return alt or ""; end
+ return str .. suffix;
+end
+
+local commands = module:shared("commands")
+local def_env = module:shared("env");
+local default_env_mt = { __index = def_env };
+
+local function redirect_output(target, session)
+ local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end });
+ env.dofile = function(name)
+ local f, err = envloadfile(name, env);
+ if not f then return f, err; end
+ return f();
+ end;
+ return env;
+end
+
+console = {};
+
+local runner_callbacks = {};
+
+function runner_callbacks:error(err)
+ module:log("error", "Traceback[shell]: %s", err);
+
+ self.data.print("Fatal error while running command, it did not complete");
+ self.data.print("Error: "..tostring(err));
+end
+
+local function send_repl_output(session, line)
+ return session.send(st.stanza("repl-output"):text(tostring(line)));
+end
+
+function console:new_session(admin_session)
+ local session = {
+ send = function (t)
+ return send_repl_output(admin_session, t);
+ end;
+ print = function (...)
+ local t = {};
+ for i=1,select("#", ...) do
+ t[i] = tostring(select(i, ...));
+ end
+ return send_repl_output(admin_session, table.concat(t, "\t"));
+ end;
+ serialize = tostring;
+ disconnect = function () admin_session:close(); end;
+ };
+ session.env = setmetatable({}, default_env_mt);
+
+ session.thread = async.runner(function (line)
+ console:process_line(session, line);
+ end, runner_callbacks, session);
+
+ -- Load up environment with helper objects
+ for name, t in pairs(def_env) do
+ if type(t) == "table" then
+ session.env[name] = setmetatable({ session = session }, { __index = t });
+ end
+ end
+
+ session.env.output:configure();
+
+ return session;
+end
+
+local function handle_line(event)
+ local session = event.origin.shell_session;
+ if not session then
+ session = console:new_session(event.origin);
+ event.origin.shell_session = session;
+ end
+ local line = event.stanza:get_text();
+ local useglobalenv;
+
+ local result = st.stanza("repl-result");
+
+ if line:match("^>") then
+ line = line:gsub("^>", "");
+ useglobalenv = true;
+ else
+ local command = line:match("^%w+") or line:match("%p");
+ if commands[command] then
+ commands[command](session, line);
+ event.origin.send(result);
+ return;
+ end
+ end
+
+ session.env._ = line;
+
+ if not useglobalenv and commands[line:lower()] then
+ commands[line:lower()](session, line);
+ event.origin.send(result);
+ return;
+ end
+
+ if useglobalenv and not session.globalenv then
+ session.globalenv = redirect_output(_G, session);
+ end
+
+ local chunkname = "=console";
+ local env = (useglobalenv and session.globalenv) or session.env or nil
+ -- luacheck: ignore 311/err
+ local chunk, err = envload("return "..line, chunkname, env);
+ if not chunk then
+ chunk, err = envload(line, chunkname, env);
+ if not chunk then
+ err = err:gsub("^%[string .-%]:%d+: ", "");
+ err = err:gsub("^:%d+: ", "");
+ err = err:gsub("'<eof>'", "the end of the line");
+ result.attr.type = "error";
+ result:text("Sorry, I couldn't understand that... "..err);
+ event.origin.send(result);
+ return;
+ end
+ end
+
+ local taskok, message = chunk();
+
+ if promise.is_promise(taskok) then
+ taskok, message = async.wait_for(taskok);
+ end
+
+ if not message then
+ if type(taskok) ~= "string" and useglobalenv then
+ taskok = session.serialize(taskok);
+ end
+ result:text("Result: "..tostring(taskok));
+ elseif (not taskok) and message then
+ result.attr.type = "error";
+ result:text("Error: "..tostring(message));
+ else
+ result:text("OK: "..tostring(message));
+ end
+
+ event.origin.send(result);
+end
+
+module:hook("admin/repl-input", function (event)
+ local ok, err = pcall(handle_line, event);
+ if not ok then
+ event.origin.send(st.stanza("repl-result", { type = "error" }):text(err));
+ end
+end);
+
+-- Console commands --
+-- These are simple commands, not valid standalone in Lua
+
+function commands.help(session, data)
+ local print = session.print;
+ local section = data:match("^help (%w+)");
+ if not section then
+ print [[Commands are divided into multiple sections. For help on a particular section, ]]
+ print [[type: help SECTION (for example, 'help c2s'). Sections are: ]]
+ print [[]]
+ print [[c2s - Commands to manage local client-to-server sessions]]
+ print [[s2s - Commands to manage sessions between this server and others]]
+ print [[http - Commands to inspect HTTP services]] -- XXX plural but there is only one so far
+ print [[module - Commands to load/reload/unload modules/plugins]]
+ print [[host - Commands to activate, deactivate and list virtual hosts]]
+ print [[user - Commands to create and delete users, and change their passwords]]
+ print [[muc - Commands to create, list and manage chat rooms]]
+ print [[server - Uptime, version, shutting down, etc.]]
+ print [[port - Commands to manage ports the server is listening on]]
+ print [[dns - Commands to manage and inspect the internal DNS resolver]]
+ print [[xmpp - Commands for sending XMPP stanzas]]
+ print [[debug - Commands for debugging the server]]
+ print [[config - Reloading the configuration, etc.]]
+ print [[console - Help regarding the console itself]]
+ elseif section == "c2s" then
+ print [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]]
+ print [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]]
+ print [[c2s:count() - Count sessions without listing them]]
+ print [[c2s:close(jid) - Close all sessions for the specified JID]]
+ print [[c2s:closeall() - Close all active c2s connections ]]
+ elseif section == "s2s" then
+ print [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]]
+ print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
+ print [[s2s:close(from, to) - Close a connection from one domain to another]]
+ print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
+ elseif section == "http" then
+ print [[http:list(hosts) - Show HTTP endpoints]]
+ elseif section == "module" then
+ print [[module:info(module, host) - Show information about a loaded module]]
+ print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
+ print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
+ print [[module:unload(module, host) - The same, but just unloads the module from memory]]
+ print [[module:list(host) - List the modules loaded on the specified host]]
+ elseif section == "host" then
+ print [[host:activate(hostname) - Activates the specified host]]
+ print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
+ print [[host:list() - List the currently-activated hosts]]
+ elseif section == "user" then
+ print [[user:create(jid, password, roles) - Create the specified user account]]
+ print [[user:password(jid, password) - Set the password for the specified user account]]
+ print [[user:roles(jid, host, roles) - Set roles for an user (see 'help roles')]]
+ print [[user:delete(jid) - Permanently remove the specified user account]]
+ print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
+ elseif section == "muc" then
+ -- TODO `muc:room():foo()` commands
+ print [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]]
+ print [[muc:list(host) - List rooms on the specified MUC component]]
+ print [[muc:room(roomjid) - Create the specified MUC room with the given config]]
+ elseif section == "server" then
+ print [[server:version() - Show the server's version number]]
+ print [[server:uptime() - Show how long the server has been running]]
+ print [[server:memory() - Show details about the server's memory usage]]
+ print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
+ elseif section == "port" then
+ print [[port:list() - Lists all network ports prosody currently listens on]]
+ print [[port:close(port, interface) - Close a port]]
+ elseif section == "dns" then
+ print [[dns:lookup(name, type, class) - Do a DNS lookup]]
+ print [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
+ print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
+ print [[dns:purge() - Clear the DNS cache]]
+ print [[dns:cache() - Show cached records]]
+ elseif section == "xmpp" then
+ print [[xmpp:ping(localhost, remotehost) -- Sends a ping to a remote XMPP server and reports the response]]
+ elseif section == "config" then
+ print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
+ print [[config:get([host,] option) - Show the value of a config option.]]
+ elseif section == "stats" then -- luacheck: ignore 542
+ -- TODO describe how stats:show() works
+ elseif section == "debug" then
+ print [[debug:logevents(host) - Enable logging of fired events on host]]
+ print [[debug:events(host, event) - Show registered event handlers]]
+ print [[debug:timers() - Show information about scheduled timers]]
+ elseif section == "console" then
+ print [[Hey! Welcome to Prosody's admin console.]]
+ print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]]
+ print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]]
+ print [[so you may have trouble using the arrow keys, etc. depending on your system.]]
+ print [[]]
+ print [[For now we offer a couple of handy shortcuts:]]
+ print [[!! - Repeat the last command]]
+ print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']]
+ print [[]]
+ print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]]
+ print [[you can prefix a command with > to escape the console sandbox, and access everything in]]
+ print [[the running server. Great fun, but be careful not to break anything :)]]
+ end
+end
+
+-- Session environment --
+-- Anything in def_env will be accessible within the session as a global variable
+
+--luacheck: ignore 212/self
+local serialize_defaults = module:get_option("console_prettyprint_settings",
+ { fatal = false; unquoted = true; maxdepth = 2; table_iterator = "pairs" })
+
+def_env.output = {};
+function def_env.output:configure(opts)
+ if type(opts) ~= "table" then
+ opts = { preset = opts };
+ end
+ if not opts.fallback then
+ -- XXX Error message passed to fallback is lost, does it matter?
+ opts.fallback = tostring;
+ end
+ for k,v in pairs(serialize_defaults) do
+ if opts[k] == nil then
+ opts[k] = v;
+ end
+ end
+ if opts.table_iterator == "pairs" then
+ opts.table_iterator = pairs;
+ elseif type(opts.table_iterator) ~= "function" then
+ opts.table_iterator = nil; -- rawpairs is the default
+ end
+ self.session.serialize = serialization.new(opts);
+end
+
+def_env.server = {};
+
+function def_env.server:insane_reload()
+ prosody.unlock_globals();
+ dofile "prosody"
+ prosody = _G.prosody;
+ return true, "Server reloaded";
+end
+
+function def_env.server:version()
+ return true, tostring(prosody.version or "unknown");
+end
+
+function def_env.server:uptime()
+ local t = os.time()-prosody.start_time;
+ local seconds = t%60;
+ t = (t - seconds)/60;
+ local minutes = t%60;
+ t = (t - minutes)/60;
+ local hours = t%24;
+ t = (t - hours)/24;
+ local days = t;
+ return true, string.format("This server has been running for %d day%s, %d hour%s and %d minute%s (since %s)",
+ days, (days ~= 1 and "s") or "", hours, (hours ~= 1 and "s") or "",
+ minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time));
+end
+
+function def_env.server:shutdown(reason, code)
+ prosody.shutdown(reason, code);
+ return true, "Shutdown initiated";
+end
+
+local function human(kb)
+ return format_number(kb*1024, "B", "b");
+end
+
+function def_env.server:memory()
+ if not has_pposix or not pposix.meminfo then
+ return true, "Lua is using "..human(collectgarbage("count"));
+ end
+ local mem, lua_mem = pposix.meminfo(), collectgarbage("count");
+ local print = self.session.print;
+ print("Process: "..human((mem.allocated+mem.allocated_mmap)/1024));
+ print(" Used: "..human(mem.used/1024).." ("..human(lua_mem).." by Lua)");
+ print(" Free: "..human(mem.unused/1024).." ("..human(mem.returnable/1024).." returnable)");
+ return true, "OK";
+end
+
+def_env.module = {};
+
+local function get_hosts_set(hosts)
+ if type(hosts) == "table" then
+ if hosts[1] then
+ return set.new(hosts);
+ elseif hosts._items then
+ return hosts;
+ end
+ elseif type(hosts) == "string" then
+ return set.new { hosts };
+ elseif hosts == nil then
+ return set.new(array.collect(keys(prosody.hosts)));
+ end
+end
+
+-- Hosts with a module or all virtualhosts if no module given
+-- matching modules_enabled in the global section
+local function get_hosts_with_module(hosts, module)
+ local hosts_set = get_hosts_set(hosts)
+ / function (host)
+ if module then
+ -- Module given, filter in hosts with this module loaded
+ if modulemanager.is_loaded(host, module) then
+ return host;
+ else
+ return nil;
+ end
+ end
+ if not hosts then
+ -- No hosts given, filter in VirtualHosts
+ if prosody.hosts[host].type == "local" then
+ return host;
+ else
+ return nil
+ end
+ end;
+ -- No module given, but hosts are, don't filter at all
+ return host;
+ end;
+ if module and modulemanager.get_module("*", module) then
+ hosts_set:add("*");
+ end
+ return hosts_set;
+end
+
+function def_env.module:info(name, hosts)
+ if not name then
+ return nil, "module name expected";
+ end
+ local print = self.session.print;
+ hosts = get_hosts_with_module(hosts, name);
+ if hosts:empty() then
+ return false, "mod_" .. name .. " does not appear to be loaded on the specified hosts";
+ end
+
+ local function item_name(item) return item.name; end
+
+ local friendly_descriptions = {
+ ["adhoc-provider"] = "Ad-hoc commands",
+ ["auth-provider"] = "Authentication provider",
+ ["http-provider"] = "HTTP services",
+ ["net-provider"] = "Network service",
+ ["storage-provider"] = "Storage driver",
+ ["measure"] = "Legacy metrics",
+ ["metric"] = "Metrics",
+ ["task"] = "Periodic task",
+ };
+ local item_formatters = {
+ ["feature"] = tostring,
+ ["identity"] = function(ident) return ident.type .. "/" .. ident.category; end,
+ ["adhoc-provider"] = item_name,
+ ["auth-provider"] = item_name,
+ ["storage-provider"] = item_name,
+ ["http-provider"] = function(item, mod) return mod:http_url(item.name, item.default_path); end,
+ ["net-provider"] = item_name,
+ ["measure"] = function(item) return item.name .. " (" .. suf(item.conf and item.conf.unit, " ") .. item.type .. ")"; end,
+ ["metric"] = function(item)
+ return ("%s (%s%s)%s"):format(item.name, suf(item.mf.unit, " "), item.mf.type_, pre(": ", item.mf.description));
+ end,
+ ["task"] = function (item) return string.format("%s (%s)", item.name or item.id, item.when); end
+ };
+
+ for host in hosts do
+ local mod = modulemanager.get_module(host, name);
+ if mod.module.host == "*" then
+ print("in global context");
+ else
+ print("on " .. tostring(prosody.hosts[mod.module.host]));
+ end
+ print(" path: " .. (mod.module.path or "n/a"));
+ if mod.module.status_message then
+ print(" status: [" .. mod.module.status_type .. "] " .. mod.module.status_message);
+ end
+ if mod.module.items and next(mod.module.items) ~= nil then
+ print(" provides:");
+ for kind, items in pairs(mod.module.items) do
+ local label = friendly_descriptions[kind] or kind:gsub("%-", " "):gsub("^%a", string.upper);
+ print(string.format(" - %s (%d item%s)", label, #items, #items > 1 and "s" or ""));
+ local formatter = item_formatters[kind];
+ if formatter then
+ for _, item in ipairs(items) do
+ print(" - " .. formatter(item, mod.module));
+ end
+ end
+ end
+ end
+ if mod.module.dependencies and next(mod.module.dependencies) ~= nil then
+ print(" dependencies:");
+ for dep in pairs(mod.module.dependencies) do
+ print(" - mod_" .. dep);
+ end
+ end
+ end
+ return true;
+end
+
+function def_env.module:load(name, hosts, config)
+ hosts = get_hosts_with_module(hosts);
+
+ -- Load the module for each host
+ local ok, err, count, mod = true, nil, 0;
+ for host in hosts do
+ if (not modulemanager.is_loaded(host, name)) then
+ mod, err = modulemanager.load(host, name, config);
+ if not mod then
+ ok = false;
+ if err == "global-module-already-loaded" then
+ if count > 0 then
+ ok, err, count = true, nil, 1;
+ end
+ break;
+ end
+ self.session.print(err or "Unknown error loading module");
+ else
+ count = count + 1;
+ self.session.print("Loaded for "..mod.module.host);
+ end
+ end
+ end
+
+ return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
+end
+
+function def_env.module:unload(name, hosts)
+ hosts = get_hosts_with_module(hosts, name);
+
+ -- Unload the module for each host
+ local ok, err, count = true, nil, 0;
+ for host in hosts do
+ if modulemanager.is_loaded(host, name) then
+ ok, err = modulemanager.unload(host, name);
+ if not ok then
+ ok = false;
+ self.session.print(err or "Unknown error unloading module");
+ else
+ count = count + 1;
+ self.session.print("Unloaded from "..host);
+ end
+ end
+ end
+ return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
+end
+
+local function _sort_hosts(a, b)
+ if a == "*" then return true
+ elseif b == "*" then return false
+ else return a:gsub("[^.]+", string.reverse):reverse() < b:gsub("[^.]+", string.reverse):reverse(); end
+end
+
+function def_env.module:reload(name, hosts)
+ hosts = array.collect(get_hosts_with_module(hosts, name)):sort(_sort_hosts)
+
+ -- Reload the module for each host
+ local ok, err, count = true, nil, 0;
+ for _, host in ipairs(hosts) do
+ if modulemanager.is_loaded(host, name) then
+ ok, err = modulemanager.reload(host, name);
+ if not ok then
+ ok = false;
+ self.session.print(err or "Unknown error reloading module");
+ else
+ count = count + 1;
+ if ok == nil then
+ ok = true;
+ end
+ self.session.print("Reloaded on "..host);
+ end
+ end
+ end
+ return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
+end
+
+function def_env.module:list(hosts)
+ hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
+
+ local print = self.session.print;
+ for _, host in ipairs(hosts) do
+ print((host == "*" and "Global" or host)..":");
+ local modules = array.collect(keys(modulemanager.get_modules(host) or {})):sort();
+ if #modules == 0 then
+ if prosody.hosts[host] then
+ print(" No modules loaded");
+ else
+ print(" Host not found");
+ end
+ else
+ for _, name in ipairs(modules) do
+ local status, status_text = modulemanager.get_module(host, name).module:get_status();
+ local status_summary = "";
+ if status == "warn" or status == "error" then
+ status_summary = (" (%s: %s)"):format(status, status_text);
+ end
+ print((" %s%s"):format(name, status_summary));
+ end
+ end
+ end
+end
+
+def_env.config = {};
+function def_env.config:load(filename, format)
+ local config_load = require "core.configmanager".load;
+ local ok, err = config_load(filename, format);
+ if not ok then
+ return false, err or "Unknown error loading config";
+ end
+ return true, "Config loaded";
+end
+
+function def_env.config:get(host, key)
+ if key == nil then
+ host, key = "*", host;
+ end
+ local config_get = require "core.configmanager".get
+ return true, serialize_config(config_get(host, key));
+end
+
+function def_env.config:reload()
+ local ok, err = prosody.reload_config();
+ return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err);
+end
+
+def_env.c2s = {};
+
+local function get_jid(session)
+ if session.username then
+ return session.full_jid or jid_join(session.username, session.host, session.resource);
+ end
+
+ local conn = session.conn;
+ local ip = session.ip or "?";
+ local clientport = conn and conn:clientport() or "?";
+ local serverip = conn and conn.server and conn:server():ip() or "?";
+ local serverport = conn and conn:serverport() or "?"
+ return jid_join("["..ip.."]:"..clientport, session.host or "["..serverip.."]:"..serverport);
+end
+
+local function get_c2s()
+ local c2s = array.collect(values(prosody.full_sessions));
+ c2s:append(array.collect(values(module:shared"/*/c2s/sessions")));
+ c2s:append(array.collect(values(module:shared"/*/bosh/sessions")));
+ c2s:unique();
+ return c2s;
+end
+
+local function _sort_by_jid(a, b)
+ if a.host == b.host then
+ if a.username == b.username then return (a.resource or "") > (b.resource or ""); end
+ return (a.username or "") > (b.username or "");
+ end
+ return _sort_hosts(a.host or "", b.host or "");
+end
+
+local function show_c2s(callback)
+ get_c2s():sort(_sort_by_jid):map(function (session)
+ callback(get_jid(session), session)
+ end);
+end
+
+function def_env.c2s:count()
+ local c2s = get_c2s();
+ return true, "Total: ".. #c2s .." clients";
+end
+
+local function get_s2s_hosts(session) --> local,remote
+ if session.direction == "outgoing" then
+ return session.host or session.from_host, session.to_host;
+ elseif session.direction == "incoming" then
+ return session.host or session.to_host, session.from_host;
+ end
+end
+
+local available_columns = {
+ jid = {
+ title = "JID";
+ width = 32;
+ key = "full_jid";
+ mapper = function(full_jid, session) return full_jid or get_jid(session) end;
+ };
+ host = {
+ title = "Host";
+ key = "host";
+ width = 22;
+ mapper = function(host, session)
+ return host or get_s2s_hosts(session) or "?";
+ end;
+ };
+ remote = {
+ title = "Remote";
+ width = 22;
+ mapper = function(_, session)
+ return select(2, get_s2s_hosts(session));
+ end;
+ };
+ port = {
+ title = "Port";
+ width = 5;
+ align = "right";
+ key = "conn";
+ mapper = function(conn) return conn:serverport(); end;
+ };
+ dir = {
+ title = "Dir";
+ width = 3;
+ key = "direction";
+ mapper = function(dir, session)
+ if session.incoming and session.outgoing then return "<->"; end
+ if dir == "outgoing" then return "-->"; end
+ if dir == "incoming" then return "<--"; end
+ end;
+ };
+ id = { title = "Session ID"; width = 20; key = "id" };
+ type = { title = "Type"; width = #"c2s_unauthed"; key = "type" };
+ method = {
+ title = "Method";
+ width = 10;
+ mapper = function(_, session)
+ if session.bosh_version then
+ return "BOSH";
+ elseif session.websocket_request then
+ return "WebSocket";
+ else
+ return "TCP";
+ end
+ end;
+ };
+ ipv = {
+ title = "IPv";
+ width = 4;
+ key = "ip";
+ mapper = function(ip) if ip then return ip:find(":") and "IPv6" or "IPv4"; end end;
+ };
+ ip = { title = "IP address"; width = 40; key = "ip" };
+ status = {
+ title = "Status";
+ width = 6;
+ key = "presence";
+ mapper = function(p)
+ if not p then return ""; end
+ return p:get_child_text("show") or "online";
+ end;
+ };
+ secure = {
+ title = "Security";
+ key = "conn";
+ width = 8;
+ mapper = function(conn, session)
+ if not session.secure then return "insecure"; end
+ if not conn or not conn:ssl() then return "secure" end
+ local sock = conn and conn:socket();
+ if not sock then return "secure"; end
+ local tls_info = sock.info and sock:info();
+ return tls_info and tls_info.protocol or "secure";
+ end;
+ };
+ encryption = {
+ title = "Encryption";
+ width = 30;
+ key = "conn";
+ mapper = function(conn)
+ local sock = conn:socket();
+ local info = sock and sock.info and sock:info();
+ if info then return info.cipher end
+ end;
+ };
+ cert = {
+ title = "Certificate";
+ key = "cert_identity_status";
+ width = 13;
+ mapper = function(cert_status, session)
+ if cert_status then return capitalize(cert_status); end
+ if session.cert_chain_status == "Invalid" then
+ local cert_errors = set.new(session.cert_chain_errors[1]);
+ if cert_errors:contains("certificate has expired") then
+ return "Expired";
+ elseif cert_errors:contains("self signed certificate") then
+ return "Self-signed";
+ end
+ return "Untrusted";
+ elseif session.cert_identity_status == "invalid" then
+ return "Mismatched";
+ end
+ return "Not validated";
+ end;
+ };
+ sni = {
+ title = "SNI";
+ width = 22;
+ mapper = function(_, session)
+ if not session.conn then return end
+ local sock = session.conn:socket();
+ return sock and sock.getsniname and sock:getsniname() or "";
+ end;
+ };
+ alpn = {
+ title = "ALPN";
+ width = 11;
+ mapper = function(_, session)
+ if not session.conn then return end
+ local sock = session.conn:socket();
+ return sock and sock.getalpn and sock:getalpn() or "";
+ end;
+ };
+ smacks = {
+ title = "SM";
+ key = "smacks";
+ width = 11;
+ mapper = function(smacks_xmlns, session)
+ if not smacks_xmlns then return "no"; end
+ if session.hibernating then return "hibernating"; end
+ return "yes";
+ end;
+ };
+ smacks_queue = {
+ title = "SM Queue";
+ key = "outgoing_stanza_queue";
+ width = 8;
+ align = "right";
+ mapper = function (queue)
+ return queue and tostring(queue:count_unacked());
+ end
+ };
+ csi = {
+ title = "CSI State";
+ key = "state";
+ -- TODO include counter
+ };
+ s2s_sasl = {
+ title = "SASL";
+ key = "external_auth";
+ width = 10;
+ mapper = capitalize
+ };
+ dialback = {
+ title = "Dialback";
+ key = "dialback_key";
+ width = 13;
+ mapper = function (dialback_key, session)
+ if not dialback_key then
+ if session.type == "s2sin" or session.type == "s2sout" then
+ return "Not used";
+ end
+ return "Not initiated";
+ elseif session.type == "s2sin_unauthed" or session.type == "s2sout_unauthed" then
+ return "Initiated";
+ else
+ return "Completed";
+ end
+ end
+ };
+};
+
+local function get_colspec(colspec, default)
+ if type(colspec) == "string" then colspec = array(colspec:gmatch("%S+")); end
+ local columns = {};
+ for i, col in pairs(colspec or default) do
+ if type(col) == "string" then
+ columns[i] = available_columns[col] or { title = capitalize(col); width = 20; key = col };
+ elseif type(col) ~= "table" then
+ return false, ("argument %d: expected string|table but got %s"):format(i, type(col));
+ else
+ columns[i] = col;
+ end
+ end
+
+ return columns;
+end
+
+function def_env.c2s:show(match_jid, colspec)
+ local print = self.session.print;
+ local columns = get_colspec(colspec, { "id"; "jid"; "ipv"; "status"; "secure"; "smacks"; "csi" });
+ local row = format_table(columns, 120);
+
+ local function match(session)
+ local jid = get_jid(session)
+ return (not match_jid) or jid:match(match_jid)
+ end
+
+ local group_by_host = true;
+ for _, col in ipairs(columns) do
+ if col.key == "full_jid" or col.key == "host" then
+ group_by_host = false;
+ break
+ end
+ end
+
+ if not group_by_host then print(row()); end
+ local currenthost = nil;
+
+ local c2s_sessions = get_c2s();
+ local total_count = #c2s_sessions;
+ c2s_sessions:filter(match):sort(_sort_by_jid);
+ local shown_count = #c2s_sessions;
+ for _, session in ipairs(c2s_sessions) do
+ if group_by_host and session.host ~= currenthost then
+ currenthost = session.host;
+ print("#",prosody.hosts[currenthost] or "Unknown host");
+ print(row());
+ end
+
+ print(row(session));
+ end
+ if total_count ~= shown_count then
+ return true, ("%d out of %d c2s sessions shown"):format(shown_count, total_count);
+ end
+ return true, ("%d c2s sessions shown"):format(total_count);
+end
+
+function def_env.c2s:show_tls(match_jid)
+ return self:show(match_jid, { "jid"; "id"; "secure"; "encryption" });
+end
+
+local function build_reason(text, condition)
+ if text or condition then
+ return {
+ text = text,
+ condition = condition or "undefined-condition",
+ };
+ end
+end
+
+function def_env.c2s:close(match_jid, text, condition)
+ local count = 0;
+ show_c2s(function (jid, session)
+ if jid == match_jid or jid_bare(jid) == match_jid then
+ count = count + 1;
+ session:close(build_reason(text, condition));
+ end
+ end);
+ return true, "Total: "..count.." sessions closed";
+end
+
+function def_env.c2s:closeall(text, condition)
+ local count = 0;
+ --luacheck: ignore 212/jid
+ show_c2s(function (jid, session)
+ count = count + 1;
+ session:close(build_reason(text, condition));
+ end);
+ return true, "Total: "..count.." sessions closed";
+end
+
+
+def_env.s2s = {};
+local function _sort_s2s(a, b)
+ local a_local, a_remote = get_s2s_hosts(a);
+ local b_local, b_remote = get_s2s_hosts(b);
+ if (a_local or "") == (b_local or "") then return _sort_hosts(a_remote or "", b_remote or ""); end
+ return _sort_hosts(a_local or "", b_local or "");
+end
+
+function def_env.s2s:show(match_jid, colspec)
+ local print = self.session.print;
+ local columns = get_colspec(colspec, { "id"; "host"; "dir"; "remote"; "ipv"; "secure"; "s2s_sasl"; "dialback" });
+ local row = format_table(columns, 132);
+
+ local function match(session)
+ local host, remote = get_s2s_hosts(session);
+ return not match_jid or (host or ""):match(match_jid) or (remote or ""):match(match_jid);
+ end
+
+ local group_by_host = true;
+ local currenthost = nil;
+ for _, col in ipairs(columns) do
+ if col.key == "host" then
+ group_by_host = false;
+ break
+ end
+ end
+
+ if not group_by_host then print(row()); end
+
+ local s2s_sessions = array(iterators.values(module:shared"/*/s2s/sessions"));
+ local total_count = #s2s_sessions;
+ s2s_sessions:filter(match):sort(_sort_s2s);
+ local shown_count = #s2s_sessions;
+
+ for _, session in ipairs(s2s_sessions) do
+ if group_by_host and currenthost ~= get_s2s_hosts(session) then
+ currenthost = get_s2s_hosts(session);
+ print("#",prosody.hosts[currenthost] or "Unknown host");
+ print(row());
+ end
+
+ print(row(session));
+ end
+ if total_count ~= shown_count then
+ return true, ("%d out of %d s2s connections shown"):format(shown_count, total_count);
+ end
+ return true, ("%d s2s connections shown"):format(total_count);
+end
+
+function def_env.s2s:show_tls(match_jid)
+ return self:show(match_jid, { "id"; "host"; "dir"; "remote"; "secure"; "encryption"; "cert" });
+end
+
+local function print_subject(print, subject)
+ for _, entry in ipairs(subject) do
+ print(
+ (" %s: %q"):format(
+ entry.name or entry.oid,
+ entry.value:gsub("[\r\n%z%c]", " ")
+ )
+ );
+ end
+end
+
+-- As much as it pains me to use the 0-based depths that OpenSSL does,
+-- I think there's going to be more confusion among operators if we
+-- break from that.
+local function print_errors(print, errors)
+ for depth, t in pairs(errors) do
+ print(
+ (" %d: %s"):format(
+ depth-1,
+ table.concat(t, "\n| ")
+ )
+ );
+ end
+end
+
+function def_env.s2s:showcert(domain)
+ local print = self.session.print;
+ local s2s_sessions = module:shared"/*/s2s/sessions";
+ local domain_sessions = set.new(array.collect(values(s2s_sessions)))
+ /function(session) return (session.to_host == domain or session.from_host == domain) and session or nil; end;
+ local cert_set = {};
+ for session in domain_sessions do
+ local conn = session.conn;
+ conn = conn and conn:socket();
+ if not conn.getpeerchain then
+ if conn.dohandshake then
+ error("This version of LuaSec does not support certificate viewing");
+ end
+ else
+ local cert = conn:getpeercertificate();
+ if cert then
+ local certs = conn:getpeerchain();
+ local digest = cert:digest("sha1");
+ if not cert_set[digest] then
+ local chain_valid, chain_errors = conn:getpeerverification();
+ cert_set[digest] = {
+ {
+ from = session.from_host,
+ to = session.to_host,
+ direction = session.direction
+ };
+ chain_valid = chain_valid;
+ chain_errors = chain_errors;
+ certs = certs;
+ };
+ else
+ table.insert(cert_set[digest], {
+ from = session.from_host,
+ to = session.to_host,
+ direction = session.direction
+ });
+ end
+ end
+ end
+ end
+ local domain_certs = array.collect(values(cert_set));
+ -- Phew. We now have a array of unique certificates presented by domain.
+ local n_certs = #domain_certs;
+
+ if n_certs == 0 then
+ return "No certificates found for "..domain;
+ end
+
+ local function _capitalize_and_colon(byte)
+ return string.upper(byte)..":";
+ end
+ local function pretty_fingerprint(hash)
+ return hash:gsub("..", _capitalize_and_colon):sub(1, -2);
+ end
+
+ for cert_info in values(domain_certs) do
+ local certs = cert_info.certs;
+ local cert = certs[1];
+ print("---")
+ print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1")));
+ print("");
+ local n_streams = #cert_info;
+ print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":");
+ for _, stream in ipairs(cert_info) do
+ if stream.direction == "incoming" then
+ print(" "..stream.to.." <- "..stream.from);
+ else
+ print(" "..stream.from.." -> "..stream.to);
+ end
+ end
+ print("");
+ local chain_valid, errors = cert_info.chain_valid, cert_info.chain_errors;
+ local valid_identity = cert_verify_identity(domain, "xmpp-server", cert);
+ if chain_valid then
+ print("Trusted certificate: Yes");
+ else
+ print("Trusted certificate: No");
+ print_errors(print, errors);
+ end
+ print("");
+ print("Issuer: ");
+ print_subject(print, cert:issuer());
+ print("");
+ print("Valid for "..domain..": "..(valid_identity and "Yes" or "No"));
+ print("Subject:");
+ print_subject(print, cert:subject());
+ end
+ print("---");
+ return ("Showing "..n_certs.." certificate"
+ ..(n_certs==1 and "" or "s")
+ .." presented by "..domain..".");
+end
+
+function def_env.s2s:close(from, to, text, condition)
+ local print, count = self.session.print, 0;
+ local s2s_sessions = module:shared"/*/s2s/sessions";
+
+ local match_id;
+ if from and not to then
+ match_id, from = from, nil;
+ elseif not to then
+ return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'";
+ elseif from == to then
+ return false, "Both from and to are the same... you can't do that :)";
+ end
+
+ for _, session in pairs(s2s_sessions) do
+ local id = session.id or (session.type..tostring(session):match("[a-f0-9]+$"));
+ if (match_id and match_id == id)
+ or (session.from_host == from and session.to_host == to) then
+ print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id));
+ (session.close or s2smanager.destroy_session)(session, build_reason(text, condition));
+ count = count + 1 ;
+ end
+ end
+ return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s");
+end
+
+function def_env.s2s:closeall(host, text, condition)
+ local count = 0;
+ local s2s_sessions = module:shared"/*/s2s/sessions";
+ for _,session in pairs(s2s_sessions) do
+ if not host or session.from_host == host or session.to_host == host then
+ session:close(build_reason(text, condition));
+ count = count + 1;
+ end
+ end
+ if count == 0 then return false, "No sessions to close.";
+ else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end
+end
+
+def_env.host = {}; def_env.hosts = def_env.host;
+
+function def_env.host:activate(hostname, config)
+ return hostmanager.activate(hostname, config);
+end
+function def_env.host:deactivate(hostname, reason)
+ return hostmanager.deactivate(hostname, reason);
+end
+
+function def_env.host:list()
+ local print = self.session.print;
+ local i = 0;
+ local type;
+ for host, host_session in iterators.sorted_pairs(prosody.hosts, _sort_hosts) do
+ i = i + 1;
+ type = host_session.type;
+ if type == "local" then
+ print(host);
+ else
+ type = module:context(host):get_option_string("component_module", type);
+ if type ~= "component" then
+ type = type .. " component";
+ end
+ print(("%s (%s)"):format(host, type));
+ end
+ end
+ return true, i.." hosts";
+end
+
+def_env.port = {};
+
+function def_env.port:list()
+ local print = self.session.print;
+ local services = portmanager.get_active_services().data;
+ local n_services, n_ports = 0, 0;
+ for service, interfaces in iterators.sorted_pairs(services) do
+ n_services = n_services + 1;
+ local ports_list = {};
+ for interface, ports in pairs(interfaces) do
+ for port in pairs(ports) do
+ table.insert(ports_list, "["..interface.."]:"..port);
+ end
+ end
+ n_ports = n_ports + #ports_list;
+ print(service..": "..table.concat(ports_list, ", "));
+ end
+ return true, n_services.." services listening on "..n_ports.." ports";
+end
+
+function def_env.port:close(close_port, close_interface)
+ close_port = assert(tonumber(close_port), "Invalid port number");
+ local n_closed = 0;
+ local services = portmanager.get_active_services().data;
+ for service, interfaces in pairs(services) do -- luacheck: ignore 213
+ for interface, ports in pairs(interfaces) do
+ if not close_interface or close_interface == interface then
+ if ports[close_port] then
+ self.session.print("Closing ["..interface.."]:"..close_port.."...");
+ local ok, err = portmanager.close(interface, close_port)
+ if not ok then
+ self.session.print("Failed to close "..interface.." "..close_port..": "..err);
+ else
+ n_closed = n_closed + 1;
+ end
+ end
+ end
+ end
+ end
+ return true, "Closed "..n_closed.." ports";
+end
+
+def_env.muc = {};
+
+local console_room_mt = {
+ __index = function (self, k) return self.room[k]; end;
+ __tostring = function (self)
+ return "MUC room <"..self.room.jid..">";
+ end;
+};
+
+local function check_muc(jid)
+ local room_name, host = jid_split(jid);
+ if not prosody.hosts[host] then
+ return nil, "No such host: "..host;
+ elseif not prosody.hosts[host].modules.muc then
+ return nil, "Host '"..host.."' is not a MUC service";
+ end
+ return room_name, host;
+end
+
+function def_env.muc:create(room_jid, config)
+ local room_name, host = check_muc(room_jid);
+ if not room_name then
+ return room_name, host;
+ end
+ if not room_name then return nil, host end
+ if config ~= nil and type(config) ~= "table" then return nil, "Config must be a table"; end
+ if prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end
+ return prosody.hosts[host].modules.muc.create_room(room_jid, config);
+end
+
+function def_env.muc:room(room_jid)
+ local room_name, host = check_muc(room_jid);
+ if not room_name then
+ return room_name, host;
+ end
+ local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid);
+ if not room_obj then
+ return nil, "No such room: "..room_jid;
+ end
+ return setmetatable({ room = room_obj }, console_room_mt);
+end
+
+function def_env.muc:list(host)
+ local host_session = prosody.hosts[host];
+ if not host_session or not host_session.modules.muc then
+ return nil, "Please supply the address of a local MUC component";
+ end
+ local print = self.session.print;
+ local c = 0;
+ for room in host_session.modules.muc.each_room() do
+ print(room.jid);
+ c = c + 1;
+ end
+ return true, c.." rooms";
+end
+
+local um = require"core.usermanager";
+
+local function coerce_roles(roles)
+ if roles == "admin" then roles = "prosody:admin"; end
+ if type(roles) == "string" then roles = { [roles] = true }; end
+ if roles[1] then for i, role in ipairs(roles) do roles[role], roles[i] = true, nil; end end
+ return roles;
+end
+
+def_env.user = {};
+function def_env.user:create(jid, password, roles)
+ local username, host = jid_split(jid);
+ if not prosody.hosts[host] then
+ return nil, "No such host: "..host;
+ elseif um.user_exists(username, host) then
+ return nil, "User exists";
+ end
+ local ok, err = um.create_user(username, password, host);
+ if ok then
+ if ok and roles then
+ roles = coerce_roles(roles);
+ local roles_ok, rerr = um.set_roles(jid, host, roles);
+ if not roles_ok then return nil, "User created, but could not set roles: " .. tostring(rerr); end
+ end
+ return true, "User created";
+ else
+ return nil, "Could not create user: "..err;
+ end
+end
+
+function def_env.user:delete(jid)
+ local username, host = jid_split(jid);
+ if not prosody.hosts[host] then
+ return nil, "No such host: "..host;
+ elseif not um.user_exists(username, host) then
+ return nil, "No such user";
+ end
+ local ok, err = um.delete_user(username, host);
+ if ok then
+ return true, "User deleted";
+ else
+ return nil, "Could not delete user: "..err;
+ end
+end
+
+function def_env.user:password(jid, password)
+ local username, host = jid_split(jid);
+ if not prosody.hosts[host] then
+ return nil, "No such host: "..host;
+ elseif not um.user_exists(username, host) then
+ return nil, "No such user";
+ end
+ local ok, err = um.set_password(username, password, host, nil);
+ if ok then
+ return true, "User password changed";
+ else
+ return nil, "Could not change password for user: "..err;
+ end
+end
+
+-- user:roles("someone@example.com", "example.com", {"prosody:admin"})
+-- user:roles("someone@example.com", {"prosody:admin"})
+function def_env.user:roles(jid, host, new_roles)
+ local username, userhost = jid_split(jid);
+ if new_roles == nil then host, new_roles = userhost, host; end
+ if host ~= "*" and not prosody.hosts[host] then
+ return nil, "No such host: "..host;
+ elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
+ return nil, "No such user";
+ end
+ if host == "*" then host = nil; end
+ return um.set_roles(jid, host, coerce_roles(new_roles));
+end
+
+-- TODO switch to table view, include roles
+function def_env.user:list(host, pat)
+ if not host then
+ return nil, "No host given";
+ elseif not prosody.hosts[host] then
+ return nil, "No such host";
+ end
+ local print = self.session.print;
+ local total, matches = 0, 0;
+ for user in um.users(host) do
+ if not pat or user:match(pat) then
+ print(user.."@"..host);
+ matches = matches + 1;
+ end
+ total = total + 1;
+ end
+ return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users";
+end
+
+def_env.xmpp = {};
+
+local new_id = require "util.id".medium;
+function def_env.xmpp:ping(localhost, remotehost, timeout)
+ localhost = select(2, jid_split(localhost));
+ remotehost = select(2, jid_split(remotehost));
+ if not localhost then
+ return nil, "Invalid sender hostname";
+ elseif not prosody.hosts[localhost] then
+ return nil, "No such local host";
+ end
+ if not remotehost then
+ return nil, "Invalid destination hostname";
+ elseif prosody.hosts[remotehost] then
+ return nil, "Both hosts are local";
+ end
+ local iq = st.iq{ from=localhost, to=remotehost, type="get", id=new_id()}
+ :tag("ping", {xmlns="urn:xmpp:ping"});
+ local time_start = time.now();
+ local print = self.session.print;
+ local function onchange(what)
+ return function(event)
+ local s2s_session = event.session;
+ if (s2s_session.from_host == localhost and s2s_session.to_host == remotehost)
+ or (s2s_session.to_host == localhost and s2s_session.from_host == remotehost) then
+ local dir = available_columns.dir.mapper(s2s_session.direction, s2s_session);
+ print(("Session %s (%s%s%s) %s (%gs)"):format(s2s_session.id, localhost, dir, remotehost, what,
+ time.now() - time_start));
+ elseif s2s_session.type == "s2sin_unauthed" and s2s_session.to_host == nil and s2s_session.from_host == nil then
+ print(("Session %s %s (%gs)"):format(s2s_session.id, what, time.now() - time_start));
+ end
+ end
+ end
+ local oncreated = onchange("created");
+ local onauthenticated = onchange("authenticated");
+ local onestablished = onchange("established");
+ local ondestroyed = onchange("destroyed");
+ module:hook("s2s-created", oncreated, 1);
+ module:context(localhost):hook("s2s-authenticated", onauthenticated, 1);
+ module:hook("s2sout-established", onestablished, 1);
+ module:hook("s2sin-established", onestablished, 1);
+ module:hook("s2s-destroyed", ondestroyed, 1);
+ return module:context(localhost):send_iq(iq, nil, timeout):finally(function()
+ module:unhook("s2s-created", oncreated);
+ module:context(localhost):unhook("s2s-authenticated", onauthenticated);
+ module:unhook("s2sout-established", onestablished);
+ module:unhook("s2sin-established", onestablished);
+ module:unhook("s2s-destroyed", ondestroyed);
+ end):next(function(pong)
+ return ("pong from %s in %gs"):format(pong.stanza.attr.from, time.now() - time_start);
+ end);
+end
+
+def_env.dns = {};
+local adns = require"net.adns";
+
+local function get_resolver(session)
+ local resolver = session.dns_resolver;
+ if not resolver then
+ resolver = adns.resolver();
+ session.dns_resolver = resolver;
+ end
+ return resolver;
+end
+
+function def_env.dns:lookup(name, typ, class)
+ local resolver = get_resolver(self.session);
+ return resolver:lookup_promise(name, typ, class)
+end
+
+function def_env.dns:addnameserver(...)
+ local resolver = get_resolver(self.session);
+ resolver._resolver:addnameserver(...)
+ return true
+end
+
+function def_env.dns:setnameserver(...)
+ local resolver = get_resolver(self.session);
+ resolver._resolver:setnameserver(...)
+ return true
+end
+
+function def_env.dns:purge()
+ local resolver = get_resolver(self.session);
+ resolver._resolver:purge()
+ return true
+end
+
+function def_env.dns:cache()
+ local resolver = get_resolver(self.session);
+ return true, "Cache:\n"..tostring(resolver._resolver.cache)
+end
+
+def_env.http = {};
+
+function def_env.http:list(hosts)
+ local print = self.session.print;
+ hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
+ local output = format_table({
+ { title = "Module", width = "20%" },
+ { title = "URL", width = "80%" },
+ }, 132);
+
+ for _, host in ipairs(hosts) do
+ local http_apps = modulemanager.get_items("http-provider", host);
+ if #http_apps > 0 then
+ local http_host = module:context(host):get_option_string("http_host");
+ if host == "*" then
+ print("Global HTTP endpoints available on all hosts:");
+ else
+ print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":"));
+ end
+ print(output());
+ for _, provider in ipairs(http_apps) do
+ local mod = provider._provided_by;
+ local url = module:context(host):http_url(provider.name, provider.default_path);
+ mod = mod and "mod_"..mod or ""
+ print(output{mod, url});
+ end
+ print("");
+ end
+ end
+
+ local default_host = module:get_option_string("http_default_host");
+ if not default_host then
+ print("HTTP requests to unknown hosts will return 404 Not Found");
+ else
+ print("HTTP requests to unknown hosts will be handled by "..default_host);
+ end
+ return true;
+end
+
+def_env.debug = {};
+
+function def_env.debug:logevents(host)
+ helpers.log_host_events(host);
+ return true;
+end
+
+function def_env.debug:events(host, event)
+ local events_obj;
+ if host and host ~= "*" then
+ if host == "http" then
+ events_obj = require "net.http.server"._events;
+ elseif not prosody.hosts[host] then
+ return false, "Unknown host: "..host;
+ else
+ events_obj = prosody.hosts[host].events;
+ end
+ else
+ events_obj = prosody.events;
+ end
+ return true, helpers.show_events(events_obj, event);
+end
+
+function def_env.debug:timers()
+ local print = self.session.print;
+ local add_task = require"util.timer".add_task;
+ local h, params = add_task.h, add_task.params;
+ local function normalize_time(t)
+ return t;
+ end
+ local function format_time(t)
+ return os.date("%F %T", math.floor(normalize_time(t)));
+ end
+ if h then
+ print("-- util.timer");
+ elseif server.timer then
+ print("-- net.server.timer");
+ h = server.timer.add_task.timers;
+ normalize_time = server.timer.to_absolute_time or normalize_time;
+ end
+ if h then
+ local timers = {};
+ for i, id in ipairs(h.ids) do
+ local t, cb = h.priorities[i], h.items[id];
+ if not params then
+ local param = cb.param;
+ if param then
+ cb = param.callback;
+ else
+ cb = cb.timer_callback or cb;
+ end
+ elseif params[id] then
+ cb = params[id].callback or cb;
+ end
+ table.insert(timers, { format_time(t), cb });
+ end
+ table.sort(timers, function (a, b) return a[1] < b[1] end);
+ for _, t in ipairs(timers) do
+ print(t[1], t[2])
+ end
+ end
+ if server.event_base then
+ local count = 0;
+ for _, v in pairs(debug.getregistry()) do
+ if type(v) == "function" and v.callback and v.callback == add_task._on_timer then
+ count = count + 1;
+ end
+ end
+ print(count .. " libevent callbacks");
+ end
+ if h then
+ local next_time = h:peek();
+ if next_time then
+ return true, ("Next event at %s (in %.6fs)"):format(format_time(next_time), normalize_time(next_time) - time.now());
+ end
+ end
+ return true;
+end
+
+-- COMPAT: debug:timers() was timer:info() for some time in trunk
+def_env.timer = { info = def_env.debug.timers };
+
+def_env.stats = {};
+
+local short_units = {
+ seconds = "s",
+ bytes = "B",
+};
+
+local stats_methods = {};
+
+function stats_methods:render_single_fancy_histogram_ex(print, prefix, metric_family, metric, cumulative)
+ local creation_timestamp, sum, count
+ local buckets = {}
+ local prev_bucket_count = 0
+ for suffix, extra_labels, value in metric:iter_samples() do
+ if suffix == "_created" then
+ creation_timestamp = value
+ elseif suffix == "_sum" then
+ sum = value
+ elseif suffix == "_count" then
+ count = value
+ else
+ local bucket_threshold = extra_labels["le"]
+ local bucket_count
+ if cumulative then
+ bucket_count = value
+ else
+ bucket_count = value - prev_bucket_count
+ prev_bucket_count = value
+ end
+ if bucket_threshold == "+Inf" then
+ t_insert(buckets, {threshold = 1/0, count = bucket_count})
+ elseif bucket_threshold ~= nil then
+ t_insert(buckets, {threshold = tonumber(bucket_threshold), count = bucket_count})
+ end
+ end
+ end
+
+ if #buckets == 0 or not creation_timestamp or not sum or not count then
+ print("[no data or not a histogram]")
+ return false
+ end
+
+ local graph_width, graph_height, wscale = #buckets, 10, 1;
+ if graph_width < 8 then
+ wscale = 8
+ elseif graph_width < 16 then
+ wscale = 4
+ elseif graph_width < 32 then
+ wscale = 2
+ end
+ local eighth_chars = " ▁▂▃▄▅▆▇█";
+
+ local max_bin_samples = 0
+ for _, bucket in ipairs(buckets) do
+ if bucket.count > max_bin_samples then
+ max_bin_samples = bucket.count
+ end
+ end
+
+ print("");
+ print(prefix)
+ print(("_"):rep(graph_width*wscale).." "..max_bin_samples);
+ for row = graph_height, 1, -1 do
+ local row_chars = {};
+ local min_eighths, max_eighths = 8, 0;
+ for i = 1, #buckets do
+ local char_eighths = math.ceil(math.max(math.min((graph_height/(max_bin_samples/buckets[i].count))-(row-1), 1), 0)*8);
+ if char_eighths < min_eighths then
+ min_eighths = char_eighths;
+ end
+ if char_eighths > max_eighths then
+ max_eighths = char_eighths;
+ end
+ if char_eighths == 0 then
+ row_chars[i] = ("-"):rep(wscale);
+ else
+ local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
+ row_chars[i] = char:rep(wscale);
+ end
+ end
+ print(table.concat(row_chars).."|- "..string.format("%.8g", math.ceil((max_bin_samples/graph_height)*(row-0.5))));
+ end
+
+ local legend_pat = string.format("%%%d.%dg", wscale-1, wscale-1)
+ local row = {}
+ for i = 1, #buckets do
+ local threshold = buckets[i].threshold
+ t_insert(row, legend_pat:format(threshold))
+ end
+ t_insert(row, " " .. metric_family.unit)
+ print(t_concat(row, "/"))
+
+ return true
+end
+
+function stats_methods:render_single_fancy_histogram(print, prefix, metric_family, metric)
+ return self:render_single_fancy_histogram_ex(print, prefix, metric_family, metric, false)
+end
+
+function stats_methods:render_single_fancy_histogram_cf(print, prefix, metric_family, metric)
+ -- cf = cumulative frequency
+ return self:render_single_fancy_histogram_ex(print, prefix, metric_family, metric, true)
+end
+
+function stats_methods:cfgraph()
+ for _, stat_info in ipairs(self) do
+ local family_name, metric_family = unpack(stat_info, 1, 2)
+ local function print(s)
+ table.insert(stat_info.output, s);
+ end
+
+ if not self:render_family(print, family_name, metric_family, self.render_single_fancy_histogram_cf) then
+ return self
+ end
+ end
+ return self;
+end
+
+function stats_methods:histogram()
+ for _, stat_info in ipairs(self) do
+ local family_name, metric_family = unpack(stat_info, 1, 2)
+ local function print(s)
+ table.insert(stat_info.output, s);
+ end
+
+ if not self:render_family(print, family_name, metric_family, self.render_single_fancy_histogram) then
+ return self
+ end
+ end
+ return self;
+end
+
+function stats_methods:render_single_counter(print, prefix, metric_family, metric)
+ local created_timestamp, current_value
+ for suffix, _, value in metric:iter_samples() do
+ if suffix == "_created" then
+ created_timestamp = value
+ elseif suffix == "_total" then
+ current_value = value
+ end
+ end
+ if current_value and created_timestamp then
+ local base_unit = short_units[metric_family.unit] or metric_family.unit
+ local unit = base_unit .. "/s"
+ local factor = 1
+ if base_unit == "s" then
+ -- be smart!
+ unit = "%"
+ factor = 100
+ elseif base_unit == "" then
+ unit = "events/s"
+ end
+ print(("%-50s %s"):format(prefix, format_number(factor * current_value / (self.now - created_timestamp), unit.." [avg]")));
+ end
+end
+
+function stats_methods:render_single_gauge(print, prefix, metric_family, metric)
+ local current_value
+ for _, _, value in metric:iter_samples() do
+ current_value = value
+ end
+ if current_value then
+ local unit = short_units[metric_family.unit] or metric_family.unit
+ print(("%-50s %s"):format(prefix, format_number(current_value, unit)));
+ end
+end
+
+function stats_methods:render_single_summary(print, prefix, metric_family, metric)
+ local sum, count
+ for suffix, _, value in metric:iter_samples() do
+ if suffix == "_sum" then
+ sum = value
+ elseif suffix == "_count" then
+ count = value
+ end
+ end
+ if sum and count then
+ local unit = short_units[metric_family.unit] or metric_family.unit
+ if count == 0 then
+ print(("%-50s %s"):format(prefix, "no obs."));
+ else
+ print(("%-50s %s"):format(prefix, format_number(sum / count, unit.."/event [avg]")));
+ end
+ end
+end
+
+function stats_methods:render_family(print, family_name, metric_family, render_func)
+ local labelkeys = metric_family.label_keys
+ if #labelkeys > 0 then
+ print(family_name)
+ for labelset, metric in metric_family:iter_metrics() do
+ local labels = {}
+ for i, k in ipairs(labelkeys) do
+ local v = labelset[i]
+ t_insert(labels, ("%s=%s"):format(k, v))
+ end
+ local prefix = " "..t_concat(labels, " ")
+ render_func(self, print, prefix, metric_family, metric)
+ end
+ else
+ for _, metric in metric_family:iter_metrics() do
+ render_func(self, print, family_name, metric_family, metric)
+ end
+ end
+end
+
+local function stats_tostring(stats)
+ local print = stats.session.print;
+ for _, stat_info in ipairs(stats) do
+ if #stat_info.output > 0 then
+ print("\n#"..stat_info[1]);
+ print("");
+ for _, v in ipairs(stat_info.output) do
+ print(v);
+ end
+ print("");
+ else
+ local metric_family = stat_info[2]
+ if metric_family.type_ == "counter" then
+ stats:render_family(print, stat_info[1], metric_family, stats.render_single_counter)
+ elseif metric_family.type_ == "gauge" or metric_family.type_ == "unknown" then
+ stats:render_family(print, stat_info[1], metric_family, stats.render_single_gauge)
+ elseif metric_family.type_ == "summary" or metric_family.type_ == "histogram" then
+ stats:render_family(print, stat_info[1], metric_family, stats.render_single_summary)
+ end
+ end
+ end
+ return #stats.." statistics displayed";
+end
+
+local stats_mt = {__index = stats_methods, __tostring = stats_tostring }
+local function new_stats_context(self)
+ -- TODO: instead of now(), it might be better to take the time of the last
+ -- interval, if the statistics backend is set to use periodic collection
+ -- Otherwise we get strange stuff like average cpu usage decreasing until
+ -- the next sample and so on.
+ return setmetatable({ session = self.session, stats = true, now = time.now() }, stats_mt);
+end
+
+function def_env.stats:show(name_filter)
+ local statsman = require "core.statsmanager"
+ local collect = statsman.collect
+ if collect then
+ -- force collection if in manual mode
+ collect()
+ end
+ local metric_registry = statsman.get_metric_registry();
+ local displayed_stats = new_stats_context(self);
+ for family_name, metric_family in iterators.sorted_pairs(metric_registry:get_metric_families()) do
+ if not name_filter or family_name:match(name_filter) then
+ table.insert(displayed_stats, {
+ family_name,
+ metric_family,
+ output = {}
+ })
+ end
+ end
+ return displayed_stats;
+end
+
+
+
+-------------
+
+function printbanner(session)
+ local option = module:get_option_string("console_banner", "full");
+ if option == "full" or option == "graphic" then
+ session.print [[
+ ____ \ / _
+ | _ \ _ __ ___ ___ _-_ __| |_ _
+ | |_) | '__/ _ \/ __|/ _ \ / _` | | | |
+ | __/| | | (_) \__ \ |_| | (_| | |_| |
+ |_| |_| \___/|___/\___/ \__,_|\__, |
+ A study in simplicity |___/
+
+]]
+ end
+ if option == "short" or option == "full" then
+ session.print("Welcome to the Prosody administration console. For a list of commands, type: help");
+ session.print("You may find more help on using this console in our online documentation at ");
+ session.print("https://prosody.im/doc/console\n");
+ end
+ if option ~= "short" and option ~= "full" and option ~= "graphic" then
+ session.print(option);
+ end
+end
diff --git a/plugins/mod_admin_socket.lua b/plugins/mod_admin_socket.lua
new file mode 100644
index 00000000..b197adae
--- /dev/null
+++ b/plugins/mod_admin_socket.lua
@@ -0,0 +1,73 @@
+module:set_global();
+
+local have_unix, unix = pcall(require, "socket.unix");
+
+if not have_unix or type(unix) ~= "table" then
+ module:log_status("error", "LuaSocket unix socket support not available or incompatible, ensure it is up to date");
+ return;
+end
+
+local server = require "net.server";
+
+local adminstream = require "util.adminstream";
+
+local socket_path = module:get_option_path("admin_socket", "prosody.sock", "data");
+
+local sessions = module:shared("sessions");
+
+local function fire_admin_event(session, stanza)
+ local event_data = {
+ origin = session, stanza = stanza;
+ };
+ local event_name;
+ if stanza.attr.xmlns then
+ event_name = "admin/"..stanza.attr.xmlns..":"..stanza.name;
+ else
+ event_name = "admin/"..stanza.name;
+ end
+ module:log("debug", "Firing %s", event_name);
+ return module:fire_event(event_name, event_data);
+end
+
+module:hook("server-stopping", function ()
+ for _, session in pairs(sessions) do
+ session:close("system-shutdown");
+ end
+ os.remove(socket_path);
+end);
+
+--- Unix domain socket management
+
+local conn, sock;
+
+local listeners = adminstream.server(sessions, fire_admin_event).listeners;
+
+local function accept_connection()
+ module:log("debug", "accepting...");
+ local client = sock:accept();
+ if not client then return; end
+ server.wrapclient(client, "unix", 0, listeners, "*a");
+end
+
+function module.load()
+ sock = unix.stream();
+ sock:settimeout(0);
+ os.remove(socket_path);
+ assert(sock:bind(socket_path));
+ assert(sock:listen());
+ if server.wrapserver then
+ conn = server.wrapserver(sock, socket_path, 0, listeners);
+ else
+ conn = server.watchfd(sock:getfd(), accept_connection);
+ end
+end
+
+function module.unload()
+ if conn then
+ conn:close();
+ end
+ if sock then
+ sock:close();
+ end
+ os.remove(socket_path);
+end
diff --git a/plugins/mod_admin_telnet.lua b/plugins/mod_admin_telnet.lua
index b0e349da..15220ec9 100644
--- a/plugins/mod_admin_telnet.lua
+++ b/plugins/mod_admin_telnet.lua
@@ -8,49 +8,68 @@
-- luacheck: ignore 212/self
module:set_global();
-
-local hostmanager = require "core.hostmanager";
-local modulemanager = require "core.modulemanager";
-local s2smanager = require "core.s2smanager";
-local portmanager = require "core.portmanager";
-local helpers = require "util.helpers";
-local server = require "net.server";
-
-local _G = _G;
-
-local prosody = _G.prosody;
+module:depends("admin_shell");
local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" };
-local iterators = require "util.iterators";
-local keys, values = iterators.keys, iterators.values;
-local jid_bare, jid_split, jid_join = import("util.jid", "bare", "prepped_split", "join");
-local set, array = require "util.set", require "util.array";
-local cert_verify_identity = require "util.x509".verify_identity;
-local envload = require "util.envload".envload;
-local envloadfile = require "util.envload".envloadfile;
-local has_pposix, pposix = pcall(require, "util.pposix");
+local async = require "util.async";
+local st = require "util.stanza";
-local commands = module:shared("commands")
-local def_env = module:shared("env");
+local def_env = module:shared("admin_shell/env");
local default_env_mt = { __index = def_env };
-local function redirect_output(target, session)
- local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end });
- env.dofile = function(name)
- local f, err = envloadfile(name, env);
- if not f then return f, err; end
- return f();
- end;
- return env;
+local function printbanner(session)
+ local option = module:get_option_string("console_banner", "full");
+ if option == "full" or option == "graphic" then
+ session.print [[
+ ____ \ / _
+ | _ \ _ __ ___ ___ _-_ __| |_ _
+ | |_) | '__/ _ \/ __|/ _ \ / _` | | | |
+ | __/| | | (_) \__ \ |_| | (_| | |_| |
+ |_| |_| \___/|___/\___/ \__,_|\__, |
+ A study in simplicity |___/
+
+]]
+ end
+ if option == "short" or option == "full" then
+ session.print("Welcome to the Prosody administration console. For a list of commands, type: help");
+ session.print("You may find more help on using this console in our online documentation at ");
+ session.print("https://prosody.im/doc/console\n");
+ end
+ if option ~= "short" and option ~= "full" and option ~= "graphic" then
+ session.print(option);
+ end
end
console = {};
+local runner_callbacks = {};
+
+function runner_callbacks:ready()
+ self.data.conn:resume();
+end
+
+function runner_callbacks:waiting()
+ self.data.conn:pause();
+end
+
+function runner_callbacks:error(err)
+ module:log("error", "Traceback[telnet]: %s", err);
+
+ self.data.print("Fatal error while running command, it did not complete");
+ self.data.print("Error: "..tostring(err));
+end
+
+
function console:new_session(conn)
local w = function(s) conn:write(s:gsub("\n", "\r\n")); end;
local session = { conn = conn;
- send = function (t) w(tostring(t)); end;
+ send = function (t)
+ if st.is_stanza(t) and (t.name == "repl-result" or t.name == "repl-output") then
+ t = "| "..t:get_text().."\n";
+ end
+ w(tostring(t));
+ end;
print = function (...)
local t = {};
for i=1,select("#", ...) do
@@ -58,10 +77,16 @@ function console:new_session(conn)
end
w("| "..table.concat(t, "\t").."\n");
end;
+ serialize = tostring;
disconnect = function () conn:close(); end;
};
session.env = setmetatable({}, default_env_mt);
+ session.thread = async.runner(function (line)
+ console:process_line(session, line);
+ session.send(string.char(0));
+ end, runner_callbacks, session);
+
-- Load up environment with helper objects
for name, t in pairs(def_env) do
if type(t) == "table" then
@@ -69,69 +94,37 @@ function console:new_session(conn)
end
end
+ session.env.output:configure();
+
return session;
end
function console:process_line(session, line)
- local useglobalenv;
-
- if line:match("^>") then
- line = line:gsub("^>", "");
- useglobalenv = true;
- elseif line == "\004" then
- commands["bye"](session, line);
+ line = line:gsub("\r?\n$", "");
+ if line == "bye" or line == "quit" or line == "exit" or line:byte() == 4 then
+ session.print("See you!");
+ session:disconnect();
return;
- else
- local command = line:match("^%w+") or line:match("%p");
- if commands[command] then
- commands[command](session, line);
- return;
- end
- end
-
- session.env._ = line;
-
- local chunkname = "=console";
- local env = (useglobalenv and redirect_output(_G, session)) or session.env or nil
- local chunk, err = envload("return "..line, chunkname, env);
- if not chunk then
- chunk, err = envload(line, chunkname, env);
- if not chunk then
- err = err:gsub("^%[string .-%]:%d+: ", "");
- err = err:gsub("^:%d+: ", "");
- err = err:gsub("'<eof>'", "the end of the line");
- session.print("Sorry, I couldn't understand that... "..err);
- return;
- end
end
+ return module:fire_event("admin/repl-input", { origin = session, stanza = st.stanza("repl-input"):text(line) });
+end
- local ranok, taskok, message = pcall(chunk);
-
- if not (ranok or message or useglobalenv) and commands[line:lower()] then
- commands[line:lower()](session, line);
- return;
- end
+local sessions = {};
- if not ranok then
- session.print("Fatal error while running command, it did not complete");
- session.print("Error: "..taskok);
- return;
- end
+function module.save()
+ return { sessions = sessions }
+end
- if not message then
- session.print("Result: "..tostring(taskok));
- return;
- elseif (not taskok) and message then
- session.print("Command completed with a problem");
- session.print("Message: "..tostring(message));
- return;
+function module.restore(data)
+ if data.sessions then
+ for conn in pairs(data.sessions) do
+ conn:setlistener(console_listener);
+ local session = console:new_session(conn);
+ sessions[conn] = session;
+ end
end
-
- session.print("OK: "..tostring(message));
end
-local sessions = {};
-
function console_listener.onconnect(conn)
-- Handle new connection
local session = console:new_session(conn);
@@ -150,8 +143,7 @@ function console_listener.onincoming(conn, data)
for line in data:gmatch("[^\n]*[\n\004]") do
if session.closed then return end
- console:process_line(session, line);
- session.send(string.char(0));
+ session.thread:run(line);
end
session.partial_data = data:match("[^\n]+$");
end
@@ -176,1382 +168,6 @@ function console_listener.ondetach(conn)
sessions[conn] = nil;
end
--- Console commands --
--- These are simple commands, not valid standalone in Lua
-
-function commands.bye(session)
- session.print("See you! :)");
- session.closed = true;
- session.disconnect();
-end
-commands.quit, commands.exit = commands.bye, commands.bye;
-
-commands["!"] = function (session, data)
- if data:match("^!!") and session.env._ then
- session.print("!> "..session.env._);
- return console_listener.onincoming(session.conn, session.env._);
- end
- local old, new = data:match("^!(.-[^\\])!(.-)!$");
- if old and new then
- local ok, res = pcall(string.gsub, session.env._, old, new);
- if not ok then
- session.print(res)
- return;
- end
- session.print("!> "..res);
- return console_listener.onincoming(session.conn, res);
- end
- session.print("Sorry, not sure what you want");
-end
-
-
-function commands.help(session, data)
- local print = session.print;
- local section = data:match("^help (%w+)");
- if not section then
- print [[Commands are divided into multiple sections. For help on a particular section, ]]
- print [[type: help SECTION (for example, 'help c2s'). Sections are: ]]
- print [[]]
- print [[c2s - Commands to manage local client-to-server sessions]]
- print [[s2s - Commands to manage sessions between this server and others]]
- print [[module - Commands to load/reload/unload modules/plugins]]
- print [[host - Commands to activate, deactivate and list virtual hosts]]
- print [[user - Commands to create and delete users, and change their passwords]]
- print [[server - Uptime, version, shutting down, etc.]]
- print [[port - Commands to manage ports the server is listening on]]
- print [[dns - Commands to manage and inspect the internal DNS resolver]]
- print [[config - Reloading the configuration, etc.]]
- print [[console - Help regarding the console itself]]
- elseif section == "c2s" then
- print [[c2s:show(jid) - Show all client sessions with the specified JID (or all if no JID given)]]
- print [[c2s:show_insecure() - Show all unencrypted client connections]]
- print [[c2s:show_secure() - Show all encrypted client connections]]
- print [[c2s:show_tls() - Show TLS cipher info for encrypted sessions]]
- print [[c2s:close(jid) - Close all sessions for the specified JID]]
- elseif section == "s2s" then
- print [[s2s:show(domain) - Show all s2s connections for the given domain (or all if no domain given)]]
- print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
- print [[s2s:close(from, to) - Close a connection from one domain to another]]
- print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
- elseif section == "module" then
- print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
- print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
- print [[module:unload(module, host) - The same, but just unloads the module from memory]]
- print [[module:list(host) - List the modules loaded on the specified host]]
- elseif section == "host" then
- print [[host:activate(hostname) - Activates the specified host]]
- print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
- print [[host:list() - List the currently-activated hosts]]
- elseif section == "user" then
- print [[user:create(jid, password) - Create the specified user account]]
- print [[user:password(jid, password) - Set the password for the specified user account]]
- print [[user:delete(jid) - Permanently remove the specified user account]]
- print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
- elseif section == "server" then
- print [[server:version() - Show the server's version number]]
- print [[server:uptime() - Show how long the server has been running]]
- print [[server:memory() - Show details about the server's memory usage]]
- print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
- elseif section == "port" then
- print [[port:list() - Lists all network ports prosody currently listens on]]
- print [[port:close(port, interface) - Close a port]]
- elseif section == "dns" then
- print [[dns:lookup(name, type, class) - Do a DNS lookup]]
- print [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
- print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
- print [[dns:purge() - Clear the DNS cache]]
- print [[dns:cache() - Show cached records]]
- elseif section == "config" then
- print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
- elseif section == "console" then
- print [[Hey! Welcome to Prosody's admin console.]]
- print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]]
- print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]]
- print [[so you may have trouble using the arrow keys, etc. depending on your system.]]
- print [[]]
- print [[For now we offer a couple of handy shortcuts:]]
- print [[!! - Repeat the last command]]
- print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']]
- print [[]]
- print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]]
- print [[you can prefix a command with > to escape the console sandbox, and access everything in]]
- print [[the running server. Great fun, but be careful not to break anything :)]]
- end
- print [[]]
-end
-
--- Session environment --
--- Anything in def_env will be accessible within the session as a global variable
-
---luacheck: ignore 212/self
-
-def_env.server = {};
-
-function def_env.server:insane_reload()
- prosody.unlock_globals();
- dofile "prosody"
- prosody = _G.prosody;
- return true, "Server reloaded";
-end
-
-function def_env.server:version()
- return true, tostring(prosody.version or "unknown");
-end
-
-function def_env.server:uptime()
- local t = os.time()-prosody.start_time;
- local seconds = t%60;
- t = (t - seconds)/60;
- local minutes = t%60;
- t = (t - minutes)/60;
- local hours = t%24;
- t = (t - hours)/24;
- local days = t;
- return true, string.format("This server has been running for %d day%s, %d hour%s and %d minute%s (since %s)",
- days, (days ~= 1 and "s") or "", hours, (hours ~= 1 and "s") or "",
- minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time));
-end
-
-function def_env.server:shutdown(reason)
- prosody.shutdown(reason);
- return true, "Shutdown initiated";
-end
-
-local function human(kb)
- local unit = "K";
- if kb > 1024 then
- kb, unit = kb/1024, "M";
- end
- return ("%0.2f%sB"):format(kb, unit);
-end
-
-function def_env.server:memory()
- if not has_pposix or not pposix.meminfo then
- return true, "Lua is using "..human(collectgarbage("count"));
- end
- local mem, lua_mem = pposix.meminfo(), collectgarbage("count");
- local print = self.session.print;
- print("Process: "..human((mem.allocated+mem.allocated_mmap)/1024));
- print(" Used: "..human(mem.used/1024).." ("..human(lua_mem).." by Lua)");
- print(" Free: "..human(mem.unused/1024).." ("..human(mem.returnable/1024).." returnable)");
- return true, "OK";
-end
-
-def_env.module = {};
-
-local function get_hosts_set(hosts, module)
- if type(hosts) == "table" then
- if hosts[1] then
- return set.new(hosts);
- elseif hosts._items then
- return hosts;
- end
- elseif type(hosts) == "string" then
- return set.new { hosts };
- elseif hosts == nil then
- local hosts_set = set.new(array.collect(keys(prosody.hosts)))
- / function (host) return (prosody.hosts[host].type == "local" or module and modulemanager.is_loaded(host, module)) and host or nil; end;
- if module and modulemanager.get_module("*", module) then
- hosts_set:add("*");
- end
- return hosts_set;
- end
-end
-
-function def_env.module:load(name, hosts, config)
- hosts = get_hosts_set(hosts);
-
- -- Load the module for each host
- local ok, err, count, mod = true, nil, 0;
- for host in hosts do
- if (not modulemanager.is_loaded(host, name)) then
- mod, err = modulemanager.load(host, name, config);
- if not mod then
- ok = false;
- if err == "global-module-already-loaded" then
- if count > 0 then
- ok, err, count = true, nil, 1;
- end
- break;
- end
- self.session.print(err or "Unknown error loading module");
- else
- count = count + 1;
- self.session.print("Loaded for "..mod.module.host);
- end
- end
- end
-
- return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
-end
-
-function def_env.module:unload(name, hosts)
- hosts = get_hosts_set(hosts, name);
-
- -- Unload the module for each host
- local ok, err, count = true, nil, 0;
- for host in hosts do
- if modulemanager.is_loaded(host, name) then
- ok, err = modulemanager.unload(host, name);
- if not ok then
- ok = false;
- self.session.print(err or "Unknown error unloading module");
- else
- count = count + 1;
- self.session.print("Unloaded from "..host);
- end
- end
- end
- return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
-end
-
-local function _sort_hosts(a, b)
- if a == "*" then return true
- elseif b == "*" then return false
- else return a < b; end
-end
-
-function def_env.module:reload(name, hosts)
- hosts = array.collect(get_hosts_set(hosts, name)):sort(_sort_hosts)
-
- -- Reload the module for each host
- local ok, err, count = true, nil, 0;
- for _, host in ipairs(hosts) do
- if modulemanager.is_loaded(host, name) then
- ok, err = modulemanager.reload(host, name);
- if not ok then
- ok = false;
- self.session.print(err or "Unknown error reloading module");
- else
- count = count + 1;
- if ok == nil then
- ok = true;
- end
- self.session.print("Reloaded on "..host);
- end
- end
- end
- return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
-end
-
-function def_env.module:list(hosts)
- if hosts == nil then
- hosts = array.collect(keys(prosody.hosts));
- table.insert(hosts, 1, "*");
- end
- if type(hosts) == "string" then
- hosts = { hosts };
- end
- if type(hosts) ~= "table" then
- return false, "Please supply a host or a list of hosts you would like to see";
- end
-
- local print = self.session.print;
- for _, host in ipairs(hosts) do
- print((host == "*" and "Global" or host)..":");
- local modules = array.collect(keys(modulemanager.get_modules(host) or {})):sort();
- if #modules == 0 then
- if prosody.hosts[host] then
- print(" No modules loaded");
- else
- print(" Host not found");
- end
- else
- for _, name in ipairs(modules) do
- print(" "..name);
- end
- end
- end
-end
-
-def_env.config = {};
-function def_env.config:load(filename, format)
- local config_load = require "core.configmanager".load;
- local ok, err = config_load(filename, format);
- if not ok then
- return false, err or "Unknown error loading config";
- end
- return true, "Config loaded";
-end
-
-function def_env.config:get(host, section, key)
- local config_get = require "core.configmanager".get
- return true, tostring(config_get(host, section, key));
-end
-
-function def_env.config:reload()
- local ok, err = prosody.reload_config();
- return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err);
-end
-
-local function common_info(session, line)
- if session.id then
- line[#line+1] = "["..session.id.."]"
- else
- line[#line+1] = "["..session.type..(tostring(session):match("%x*$")).."]"
- end
-end
-
-local function session_flags(session, line)
- line = line or {};
- common_info(session, line);
- if session.type == "c2s" then
- local status, priority = "unavailable", tostring(session.priority or "-");
- if session.presence then
- status = session.presence:get_child_text("show") or "available";
- end
- line[#line+1] = status.."("..priority..")";
- end
- if session.cert_identity_status == "valid" then
- line[#line+1] = "(authenticated)";
- end
- if session.secure then
- line[#line+1] = "(encrypted)";
- end
- if session.compressed then
- line[#line+1] = "(compressed)";
- end
- if session.smacks then
- line[#line+1] = "(sm)";
- end
- if session.ip and session.ip:match(":") then
- line[#line+1] = "(IPv6)";
- end
- if session.remote then
- line[#line+1] = "(remote)";
- end
- return table.concat(line, " ");
-end
-
-local function tls_info(session, line)
- line = line or {};
- common_info(session, line);
- if session.secure then
- local sock = session.conn and session.conn.socket and session.conn:socket();
- if sock then
- local info = sock.info and sock:info();
- if info then
- line[#line+1] = ("(%s with %s)"):format(info.protocol, info.cipher);
- else
- -- TLS session might not be ready yet
- line[#line+1] = "(cipher info unavailable)";
- end
- end
- else
- line[#line+1] = "(insecure)";
- end
- return table.concat(line, " ");
-end
-
-def_env.c2s = {};
-
-local function get_jid(session)
- if session.username then
- return session.full_jid or jid_join(session.username, session.host, session.resource);
- end
-
- local conn = session.conn;
- local ip = session.ip or "?";
- local clientport = conn and conn:clientport() or "?";
- local serverip = conn and conn.server and conn:server():ip() or "?";
- local serverport = conn and conn:serverport() or "?"
- return jid_join("["..ip.."]:"..clientport, session.host or "["..serverip.."]:"..serverport);
-end
-
-local function show_c2s(callback)
- local c2s = array.collect(values(module:shared"/*/c2s/sessions"));
- c2s:sort(function(a, b)
- if a.host == b.host then
- if a.username == b.username then
- return (a.resource or "") > (b.resource or "");
- end
- return (a.username or "") > (b.username or "");
- end
- return (a.host or "") > (b.host or "");
- end):map(function (session)
- callback(get_jid(session), session)
- end);
-end
-
-function def_env.c2s:count()
- return true, "Total: ".. iterators.count(values(module:shared"/*/c2s/sessions")) .." clients";
-end
-
-function def_env.c2s:show(match_jid, annotate)
- local print, count = self.session.print, 0;
- annotate = annotate or session_flags;
- local curr_host = false;
- show_c2s(function (jid, session)
- if curr_host ~= session.host then
- curr_host = session.host;
- print(curr_host or "(not connected to any host yet)");
- end
- if (not match_jid) or jid:match(match_jid) then
- count = count + 1;
- print(annotate(session, { " ", jid }));
- end
- end);
- return true, "Total: "..count.." clients";
-end
-
-function def_env.c2s:show_insecure(match_jid)
- local print, count = self.session.print, 0;
- show_c2s(function (jid, session)
- if ((not match_jid) or jid:match(match_jid)) and not session.secure then
- count = count + 1;
- print(jid);
- end
- end);
- return true, "Total: "..count.." insecure client connections";
-end
-
-function def_env.c2s:show_secure(match_jid)
- local print, count = self.session.print, 0;
- show_c2s(function (jid, session)
- if ((not match_jid) or jid:match(match_jid)) and session.secure then
- count = count + 1;
- print(jid);
- end
- end);
- return true, "Total: "..count.." secure client connections";
-end
-
-function def_env.c2s:show_tls(match_jid)
- return self:show(match_jid, tls_info);
-end
-
-function def_env.c2s:close(match_jid)
- local count = 0;
- show_c2s(function (jid, session)
- if jid == match_jid or jid_bare(jid) == match_jid then
- count = count + 1;
- session:close();
- end
- end);
- return true, "Total: "..count.." sessions closed";
-end
-
-
-def_env.s2s = {};
-function def_env.s2s:show(match_jid, annotate)
- local print = self.session.print;
- annotate = annotate or session_flags;
-
- local count_in, count_out = 0,0;
- local s2s_list = { };
-
- local s2s_sessions = module:shared"/*/s2s/sessions";
- for _, session in pairs(s2s_sessions) do
- local remotehost, localhost, direction;
- if session.direction == "outgoing" then
- direction = "->";
- count_out = count_out + 1;
- remotehost, localhost = session.to_host or "?", session.from_host or "?";
- else
- direction = "<-";
- count_in = count_in + 1;
- remotehost, localhost = session.from_host or "?", session.to_host or "?";
- end
- local sess_lines = { l = localhost, r = remotehost,
- annotate(session, { "", direction, remotehost or "?" })};
-
- if (not match_jid) or remotehost:match(match_jid) or localhost:match(match_jid) then
- table.insert(s2s_list, sess_lines);
- -- luacheck: ignore 421/print
- local print = function (s) table.insert(sess_lines, " "..s); end
- if session.sendq then
- print("There are "..#session.sendq.." queued outgoing stanzas for this connection");
- end
- if session.type == "s2sout_unauthed" then
- if session.connecting then
- print("Connection not yet established");
- if not session.srv_hosts then
- if not session.conn then
- print("We do not yet have a DNS answer for this host's SRV records");
- else
- print("This host has no SRV records, using A record instead");
- end
- elseif session.srv_choice then
- print("We are on SRV record "..session.srv_choice.." of "..#session.srv_hosts);
- local srv_choice = session.srv_hosts[session.srv_choice];
- print("Using "..(srv_choice.target or ".")..":"..(srv_choice.port or 5269));
- end
- elseif session.notopen then
- print("The <stream> has not yet been opened");
- elseif not session.dialback_key then
- print("Dialback has not been initiated yet");
- elseif session.dialback_key then
- print("Dialback has been requested, but no result received");
- end
- end
- if session.type == "s2sin_unauthed" then
- print("Connection not yet authenticated");
- elseif session.type == "s2sin" then
- for name in pairs(session.hosts) do
- if name ~= session.from_host then
- print("also hosts "..tostring(name));
- end
- end
- end
- end
- end
-
- -- Sort by local host, then remote host
- table.sort(s2s_list, function(a,b)
- if a.l == b.l then return a.r < b.r; end
- return a.l < b.l;
- end);
- local lasthost;
- for _, sess_lines in ipairs(s2s_list) do
- if sess_lines.l ~= lasthost then print(sess_lines.l); lasthost=sess_lines.l end
- for _, line in ipairs(sess_lines) do print(line); end
- end
- return true, "Total: "..count_out.." outgoing, "..count_in.." incoming connections";
-end
-
-function def_env.s2s:show_tls(match_jid)
- return self:show(match_jid, tls_info);
-end
-
-local function print_subject(print, subject)
- for _, entry in ipairs(subject) do
- print(
- (" %s: %q"):format(
- entry.name or entry.oid,
- entry.value:gsub("[\r\n%z%c]", " ")
- )
- );
- end
-end
-
--- As much as it pains me to use the 0-based depths that OpenSSL does,
--- I think there's going to be more confusion among operators if we
--- break from that.
-local function print_errors(print, errors)
- for depth, t in pairs(errors) do
- print(
- (" %d: %s"):format(
- depth-1,
- table.concat(t, "\n| ")
- )
- );
- end
-end
-
-function def_env.s2s:showcert(domain)
- local print = self.session.print;
- local s2s_sessions = module:shared"/*/s2s/sessions";
- local domain_sessions = set.new(array.collect(values(s2s_sessions)))
- /function(session) return (session.to_host == domain or session.from_host == domain) and session or nil; end;
- local cert_set = {};
- for session in domain_sessions do
- local conn = session.conn;
- conn = conn and conn:socket();
- if not conn.getpeerchain then
- if conn.dohandshake then
- error("This version of LuaSec does not support certificate viewing");
- end
- else
- local cert = conn:getpeercertificate();
- if cert then
- local certs = conn:getpeerchain();
- local digest = cert:digest("sha1");
- if not cert_set[digest] then
- local chain_valid, chain_errors = conn:getpeerverification();
- cert_set[digest] = {
- {
- from = session.from_host,
- to = session.to_host,
- direction = session.direction
- };
- chain_valid = chain_valid;
- chain_errors = chain_errors;
- certs = certs;
- };
- else
- table.insert(cert_set[digest], {
- from = session.from_host,
- to = session.to_host,
- direction = session.direction
- });
- end
- end
- end
- end
- local domain_certs = array.collect(values(cert_set));
- -- Phew. We now have a array of unique certificates presented by domain.
- local n_certs = #domain_certs;
-
- if n_certs == 0 then
- return "No certificates found for "..domain;
- end
-
- local function _capitalize_and_colon(byte)
- return string.upper(byte)..":";
- end
- local function pretty_fingerprint(hash)
- return hash:gsub("..", _capitalize_and_colon):sub(1, -2);
- end
-
- for cert_info in values(domain_certs) do
- local certs = cert_info.certs;
- local cert = certs[1];
- print("---")
- print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1")));
- print("");
- local n_streams = #cert_info;
- print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":");
- for _, stream in ipairs(cert_info) do
- if stream.direction == "incoming" then
- print(" "..stream.to.." <- "..stream.from);
- else
- print(" "..stream.from.." -> "..stream.to);
- end
- end
- print("");
- local chain_valid, errors = cert_info.chain_valid, cert_info.chain_errors;
- local valid_identity = cert_verify_identity(domain, "xmpp-server", cert);
- if chain_valid then
- print("Trusted certificate: Yes");
- else
- print("Trusted certificate: No");
- print_errors(print, errors);
- end
- print("");
- print("Issuer: ");
- print_subject(print, cert:issuer());
- print("");
- print("Valid for "..domain..": "..(valid_identity and "Yes" or "No"));
- print("Subject:");
- print_subject(print, cert:subject());
- end
- print("---");
- return ("Showing "..n_certs.." certificate"
- ..(n_certs==1 and "" or "s")
- .." presented by "..domain..".");
-end
-
-function def_env.s2s:close(from, to)
- local print, count = self.session.print, 0;
- local s2s_sessions = module:shared"/*/s2s/sessions";
-
- local match_id;
- if from and not to then
- match_id, from = from, nil;
- elseif not to then
- return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'";
- elseif from == to then
- return false, "Both from and to are the same... you can't do that :)";
- end
-
- for _, session in pairs(s2s_sessions) do
- local id = session.type..tostring(session):match("[a-f0-9]+$");
- if (match_id and match_id == id)
- or (session.from_host == from and session.to_host == to) then
- print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id));
- (session.close or s2smanager.destroy_session)(session);
- count = count + 1 ;
- end
- end
- return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s");
-end
-
-function def_env.s2s:closeall(host)
- local count = 0;
- local s2s_sessions = module:shared"/*/s2s/sessions";
- for _,session in pairs(s2s_sessions) do
- if not host or session.from_host == host or session.to_host == host then
- session:close();
- count = count + 1;
- end
- end
- if count == 0 then return false, "No sessions to close.";
- else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end
-end
-
-def_env.host = {}; def_env.hosts = def_env.host;
-
-function def_env.host:activate(hostname, config)
- return hostmanager.activate(hostname, config);
-end
-function def_env.host:deactivate(hostname, reason)
- return hostmanager.deactivate(hostname, reason);
-end
-
-function def_env.host:list()
- local print = self.session.print;
- local i = 0;
- local type;
- for host, host_session in iterators.sorted_pairs(prosody.hosts) do
- i = i + 1;
- type = host_session.type;
- if type == "local" then
- print(host);
- else
- type = module:context(host):get_option_string("component_module", type);
- if type ~= "component" then
- type = type .. " component";
- end
- print(("%s (%s)"):format(host, type));
- end
- end
- return true, i.." hosts";
-end
-
-def_env.port = {};
-
-function def_env.port:list()
- local print = self.session.print;
- local services = portmanager.get_active_services().data;
- local n_services, n_ports = 0, 0;
- for service, interfaces in iterators.sorted_pairs(services) do
- n_services = n_services + 1;
- local ports_list = {};
- for interface, ports in pairs(interfaces) do
- for port in pairs(ports) do
- table.insert(ports_list, "["..interface.."]:"..port);
- end
- end
- n_ports = n_ports + #ports_list;
- print(service..": "..table.concat(ports_list, ", "));
- end
- return true, n_services.." services listening on "..n_ports.." ports";
-end
-
-function def_env.port:close(close_port, close_interface)
- close_port = assert(tonumber(close_port), "Invalid port number");
- local n_closed = 0;
- local services = portmanager.get_active_services().data;
- for service, interfaces in pairs(services) do -- luacheck: ignore 213
- for interface, ports in pairs(interfaces) do
- if not close_interface or close_interface == interface then
- if ports[close_port] then
- self.session.print("Closing ["..interface.."]:"..close_port.."...");
- local ok, err = portmanager.close(interface, close_port)
- if not ok then
- self.session.print("Failed to close "..interface.." "..close_port..": "..err);
- else
- n_closed = n_closed + 1;
- end
- end
- end
- end
- end
- return true, "Closed "..n_closed.." ports";
-end
-
-def_env.muc = {};
-
-local console_room_mt = {
- __index = function (self, k) return self.room[k]; end;
- __tostring = function (self)
- return "MUC room <"..self.room.jid..">";
- end;
-};
-
-local function check_muc(jid)
- local room_name, host = jid_split(jid);
- if not prosody.hosts[host] then
- return nil, "No such host: "..host;
- elseif not prosody.hosts[host].modules.muc then
- return nil, "Host '"..host.."' is not a MUC service";
- end
- return room_name, host;
-end
-
-function def_env.muc:create(room_jid, config)
- local room_name, host = check_muc(room_jid);
- if not room_name then
- return room_name, host;
- end
- if not room_name then return nil, host end
- if config ~= nil and type(config) ~= "table" then return nil, "Config must be a table"; end
- if prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end
- return prosody.hosts[host].modules.muc.create_room(room_jid, config);
-end
-
-function def_env.muc:room(room_jid)
- local room_name, host = check_muc(room_jid);
- if not room_name then
- return room_name, host;
- end
- local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid);
- if not room_obj then
- return nil, "No such room: "..room_jid;
- end
- return setmetatable({ room = room_obj }, console_room_mt);
-end
-
-function def_env.muc:list(host)
- local host_session = prosody.hosts[host];
- if not host_session or not host_session.modules.muc then
- return nil, "Please supply the address of a local MUC component";
- end
- local print = self.session.print;
- local c = 0;
- for room in host_session.modules.muc.each_room() do
- print(room.jid);
- c = c + 1;
- end
- return true, c.." rooms";
-end
-
-local um = require"core.usermanager";
-
-def_env.user = {};
-function def_env.user:create(jid, password)
- local username, host = jid_split(jid);
- if not prosody.hosts[host] then
- return nil, "No such host: "..host;
- elseif um.user_exists(username, host) then
- return nil, "User exists";
- end
- local ok, err = um.create_user(username, password, host);
- if ok then
- return true, "User created";
- else
- return nil, "Could not create user: "..err;
- end
-end
-
-function def_env.user:delete(jid)
- local username, host = jid_split(jid);
- if not prosody.hosts[host] then
- return nil, "No such host: "..host;
- elseif not um.user_exists(username, host) then
- return nil, "No such user";
- end
- local ok, err = um.delete_user(username, host);
- if ok then
- return true, "User deleted";
- else
- return nil, "Could not delete user: "..err;
- end
-end
-
-function def_env.user:password(jid, password)
- local username, host = jid_split(jid);
- if not prosody.hosts[host] then
- return nil, "No such host: "..host;
- elseif not um.user_exists(username, host) then
- return nil, "No such user";
- end
- local ok, err = um.set_password(username, password, host, nil);
- if ok then
- return true, "User password changed";
- else
- return nil, "Could not change password for user: "..err;
- end
-end
-
-function def_env.user:list(host, pat)
- if not host then
- return nil, "No host given";
- elseif not prosody.hosts[host] then
- return nil, "No such host";
- end
- local print = self.session.print;
- local total, matches = 0, 0;
- for user in um.users(host) do
- if not pat or user:match(pat) then
- print(user.."@"..host);
- matches = matches + 1;
- end
- total = total + 1;
- end
- return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users";
-end
-
-def_env.xmpp = {};
-
-local st = require "util.stanza";
-function def_env.xmpp:ping(localhost, remotehost)
- if prosody.hosts[localhost] then
- module:send(st.iq{ from=localhost, to=remotehost, type="get", id="ping" }
- :tag("ping", {xmlns="urn:xmpp:ping"}), prosody.hosts[localhost]);
- return true, "Sent ping";
- else
- return nil, "No such host";
- end
-end
-
-def_env.dns = {};
-local adns = require"net.adns";
-
-local function get_resolver(session)
- local resolver = session.dns_resolver;
- if not resolver then
- resolver = adns.resolver();
- session.dns_resolver = resolver;
- end
- return resolver;
-end
-
-function def_env.dns:lookup(name, typ, class)
- local resolver = get_resolver(self.session);
- local ret = "Query sent";
- local print = self.session.print;
- local function handler(...)
- ret = "Got response";
- print(...);
- end
- resolver:lookup(handler, name, typ, class);
- return true, ret;
-end
-
-function def_env.dns:addnameserver(...)
- local resolver = get_resolver(self.session);
- resolver._resolver:addnameserver(...)
- return true
-end
-
-function def_env.dns:setnameserver(...)
- local resolver = get_resolver(self.session);
- resolver._resolver:setnameserver(...)
- return true
-end
-
-function def_env.dns:purge()
- local resolver = get_resolver(self.session);
- resolver._resolver:purge()
- return true
-end
-
-function def_env.dns:cache()
- local resolver = get_resolver(self.session);
- return true, "Cache:\n"..tostring(resolver._resolver.cache)
-end
-
-def_env.http = {};
-
-function def_env.http:list()
- local print = self.session.print;
-
- for host in pairs(prosody.hosts) do
- local http_apps = modulemanager.get_items("http-provider", host);
- if #http_apps > 0 then
- local http_host = module:context(host):get_option_string("http_host");
- print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":"));
- for _, provider in ipairs(http_apps) do
- local url = module:context(host):http_url(provider.name, provider.default_path);
- print("", url);
- end
- print("");
- end
- end
-
- local default_host = module:get_option_string("http_default_host");
- if not default_host then
- print("HTTP requests to unknown hosts will return 404 Not Found");
- else
- print("HTTP requests to unknown hosts will be handled by "..default_host);
- end
- return true;
-end
-
-def_env.debug = {};
-
-function def_env.debug:logevents(host)
- helpers.log_host_events(host);
- return true;
-end
-
-function def_env.debug:events(host, event)
- local events_obj;
- if host and host ~= "*" then
- if host == "http" then
- events_obj = require "net.http.server"._events;
- elseif not prosody.hosts[host] then
- return false, "Unknown host: "..host;
- else
- events_obj = prosody.hosts[host].events;
- end
- else
- events_obj = prosody.events;
- end
- return true, helpers.show_events(events_obj, event);
-end
-
-function def_env.debug:timers()
- local socket = require "socket";
- local print = self.session.print;
- local add_task = require"util.timer".add_task;
- local h, params = add_task.h, add_task.params;
- if h then
- print("-- util.timer");
- for i, id in ipairs(h.ids) do
- if not params[id] then
- print(os.date("%F %T", h.priorities[i]), h.items[id]);
- elseif not params[id].callback then
- print(os.date("%F %T", h.priorities[i]), h.items[id], unpack(params[id]));
- else
- print(os.date("%F %T", h.priorities[i]), params[id].callback, unpack(params[id]));
- end
- end
- end
- if server.event_base then
- local count = 0;
- for _, v in pairs(debug.getregistry()) do
- if type(v) == "function" and v.callback and v.callback == add_task._on_timer then
- count = count + 1;
- end
- end
- print(count .. " libevent callbacks");
- end
- if h then
- local next_time = h:peek();
- if next_time then
- return true, os.date("Next event at %F %T (in %%.6fs)", next_time):format(next_time - socket.gettime());
- end
- end
- return true;
-end
-
--- COMPAT: debug:timers() was timer:info() for some time in trunk
-def_env.timer = { info = def_env.debug.timers };
-
-module:hook("server-stopping", function(event)
- for _, session in pairs(sessions) do
- session.print("Shutting down: "..(event.reason or "unknown reason"));
- end
-end);
-
-def_env.stats = {};
-
-local function format_stat(type, value, ref_value)
- ref_value = ref_value or value;
- --do return tostring(value) end
- if type == "duration" then
- if ref_value < 0.001 then
- return ("%d µs"):format(value*1000000);
- elseif ref_value < 0.9 then
- return ("%0.2f ms"):format(value*1000);
- end
- return ("%0.2f"):format(value);
- elseif type == "size" then
- if ref_value > 1048576 then
- return ("%d MB"):format(value/1048576);
- elseif ref_value > 1024 then
- return ("%d KB"):format(value/1024);
- end
- return ("%d bytes"):format(value);
- elseif type == "rate" then
- if ref_value < 0.9 then
- return ("%0.2f/min"):format(value*60);
- end
- return ("%0.2f/sec"):format(value);
- end
- return tostring(value);
-end
-
-local stats_methods = {};
-function stats_methods:bounds(_lower, _upper)
- for _, stat_info in ipairs(self) do
- local data = stat_info[4];
- if data then
- local lower = _lower or data.min;
- local upper = _upper or data.max;
- local new_data = {
- min = lower;
- max = upper;
- samples = {};
- sample_count = 0;
- count = data.count;
- units = data.units;
- };
- local sum = 0;
- for _, v in ipairs(data.samples) do
- if v > upper then
- break;
- elseif v>=lower then
- table.insert(new_data.samples, v);
- sum = sum + v;
- end
- end
- new_data.sample_count = #new_data.samples;
- stat_info[4] = new_data;
- stat_info[3] = sum/new_data.sample_count;
- end
- end
- return self;
-end
-
-function stats_methods:trim(lower, upper)
- upper = upper or (100-lower);
- local statistics = require "util.statistics";
- for _, stat_info in ipairs(self) do
- -- Strip outliers
- local data = stat_info[4];
- if data then
- local new_data = {
- min = statistics.get_percentile(data, lower);
- max = statistics.get_percentile(data, upper);
- samples = {};
- sample_count = 0;
- count = data.count;
- units = data.units;
- };
- local sum = 0;
- for _, v in ipairs(data.samples) do
- if v > new_data.max then
- break;
- elseif v>=new_data.min then
- table.insert(new_data.samples, v);
- sum = sum + v;
- end
- end
- new_data.sample_count = #new_data.samples;
- stat_info[4] = new_data;
- stat_info[3] = sum/new_data.sample_count;
- end
- end
- return self;
-end
-
-function stats_methods:max(upper)
- return self:bounds(nil, upper);
-end
-
-function stats_methods:min(lower)
- return self:bounds(lower, nil);
-end
-
-function stats_methods:summary()
- local statistics = require "util.statistics";
- for _, stat_info in ipairs(self) do
- local type, value, data = stat_info[2], stat_info[3], stat_info[4];
- if data and data.samples then
- table.insert(stat_info.output, string.format("Count: %d (%d captured)",
- data.count,
- data.sample_count
- ));
- table.insert(stat_info.output, string.format("Min: %s Mean: %s Max: %s",
- format_stat(type, data.min),
- format_stat(type, value),
- format_stat(type, data.max)
- ));
- table.insert(stat_info.output, string.format("Q1: %s Median: %s Q3: %s",
- format_stat(type, statistics.get_percentile(data, 25)),
- format_stat(type, statistics.get_percentile(data, 50)),
- format_stat(type, statistics.get_percentile(data, 75))
- ));
- end
- end
- return self;
-end
-
-function stats_methods:cfgraph()
- for _, stat_info in ipairs(self) do
- local name, type, value, data = unpack(stat_info, 1, 4);
- local function print(s)
- table.insert(stat_info.output, s);
- end
-
- if data and data.sample_count and data.sample_count > 0 then
- local raw_histogram = require "util.statistics".get_histogram(data);
-
- local graph_width, graph_height = 50, 10;
- local eighth_chars = " ▁▂▃▄▅▆▇█";
-
- local range = data.max - data.min;
-
- if range > 0 then
- local x_scaling = #raw_histogram/graph_width;
- local histogram = {};
- for i = 1, graph_width do
- histogram[i] = math.max(raw_histogram[i*x_scaling-1] or 0, raw_histogram[i*x_scaling] or 0);
- end
-
- print("");
- print(("_"):rep(52)..format_stat(type, data.max));
- for row = graph_height, 1, -1 do
- local row_chars = {};
- local min_eighths, max_eighths = 8, 0;
- for i = 1, #histogram do
- local char_eighths = math.ceil(math.max(math.min((graph_height/(data.max/histogram[i]))-(row-1), 1), 0)*8);
- if char_eighths < min_eighths then
- min_eighths = char_eighths;
- end
- if char_eighths > max_eighths then
- max_eighths = char_eighths;
- end
- if char_eighths == 0 then
- row_chars[i] = "-";
- else
- local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
- row_chars[i] = char;
- end
- end
- print(table.concat(row_chars).."|-"..format_stat(type, data.max/(graph_height/(row-0.5))));
- end
- print(("\\ "):rep(11));
- local x_labels = {};
- for i = 1, 11 do
- local s = ("%-4s"):format((i-1)*10);
- if #s > 4 then
- s = s:sub(1, 3).."…";
- end
- x_labels[i] = s;
- end
- print(" "..table.concat(x_labels, " "));
- local units = "%";
- local margin = math.floor((graph_width-#units)/2);
- print((" "):rep(margin)..units);
- else
- print("[range too small to graph]");
- end
- print("");
- end
- end
- return self;
-end
-
-function stats_methods:histogram()
- for _, stat_info in ipairs(self) do
- local name, type, value, data = unpack(stat_info, 1, 4);
- local function print(s)
- table.insert(stat_info.output, s);
- end
-
- if not data then
- print("[no data]");
- return self;
- elseif not data.sample_count then
- print("[not a sampled metric type]");
- return self;
- end
-
- local graph_width, graph_height = 50, 10;
- local eighth_chars = " ▁▂▃▄▅▆▇█";
-
- local range = data.max - data.min;
-
- if range > 0 then
- local n_buckets = graph_width;
-
- local histogram = {};
- for i = 1, n_buckets do
- histogram[i] = 0;
- end
- local max_bin_samples = 0;
- for _, d in ipairs(data.samples) do
- local bucket = math.floor(1+(n_buckets-1)/(range/(d-data.min)));
- histogram[bucket] = histogram[bucket] + 1;
- if histogram[bucket] > max_bin_samples then
- max_bin_samples = histogram[bucket];
- end
- end
-
- print("");
- print(("_"):rep(52)..max_bin_samples);
- for row = graph_height, 1, -1 do
- local row_chars = {};
- local min_eighths, max_eighths = 8, 0;
- for i = 1, #histogram do
- local char_eighths = math.ceil(math.max(math.min((graph_height/(max_bin_samples/histogram[i]))-(row-1), 1), 0)*8);
- if char_eighths < min_eighths then
- min_eighths = char_eighths;
- end
- if char_eighths > max_eighths then
- max_eighths = char_eighths;
- end
- if char_eighths == 0 then
- row_chars[i] = "-";
- else
- local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
- row_chars[i] = char;
- end
- end
- print(table.concat(row_chars).."|-"..math.ceil((max_bin_samples/graph_height)*(row-0.5)));
- end
- print(("\\ "):rep(11));
- local x_labels = {};
- for i = 1, 11 do
- local s = ("%-4s"):format(format_stat(type, data.min+range*i/11, data.min):match("^%S+"));
- if #s > 4 then
- s = s:sub(1, 3).."…";
- end
- x_labels[i] = s;
- end
- print(" "..table.concat(x_labels, " "));
- local units = format_stat(type, data.min):match("%s+(.+)$") or data.units or "";
- local margin = math.floor((graph_width-#units)/2);
- print((" "):rep(margin)..units);
- else
- print("[range too small to graph]");
- end
- print("");
- end
- return self;
-end
-
-local function stats_tostring(stats)
- local print = stats.session.print;
- for _, stat_info in ipairs(stats) do
- if #stat_info.output > 0 then
- print("\n#"..stat_info[1]);
- print("");
- for _, v in ipairs(stat_info.output) do
- print(v);
- end
- print("");
- else
- print(("%-50s %s"):format(stat_info[1], format_stat(stat_info[2], stat_info[3])));
- end
- end
- return #stats.." statistics displayed";
-end
-
-local stats_mt = {__index = stats_methods, __tostring = stats_tostring }
-local function new_stats_context(self)
- return setmetatable({ session = self.session, stats = true }, stats_mt);
-end
-
-function def_env.stats:show(filter)
- local stats, changed, extra = require "core.statsmanager".get_stats();
- local available, displayed = 0, 0;
- local displayed_stats = new_stats_context(self);
- for name, value in pairs(stats) do
- available = available + 1;
- if not filter or name:match(filter) then
- displayed = displayed + 1;
- local type = name:match(":(%a+)$");
- table.insert(displayed_stats, {
- name, type, value, extra[name];
- output = {};
- });
- end
- end
- return displayed_stats;
-end
-
-
-
--------------
-
-function printbanner(session)
- local option = module:get_option_string("console_banner", "full");
- if option == "full" or option == "graphic" then
- session.print [[
- ____ \ / _
- | _ \ _ __ ___ ___ _-_ __| |_ _
- | |_) | '__/ _ \/ __|/ _ \ / _` | | | |
- | __/| | | (_) \__ \ |_| | (_| | |_| |
- |_| |_| \___/|___/\___/ \__,_|\__, |
- A study in simplicity |___/
-
-]]
- end
- if option == "short" or option == "full" then
- session.print("Welcome to the Prosody administration console. For a list of commands, type: help");
- session.print("You may find more help on using this console in our online documentation at ");
- session.print("https://prosody.im/doc/console\n");
- end
- if option ~= "short" and option ~= "full" and option ~= "graphic" then
- session.print(option);
- end
-end
-
module:provides("net", {
name = "console";
listener = console_listener;
diff --git a/plugins/mod_announce.lua b/plugins/mod_announce.lua
index 970a273a..c742ebb8 100644
--- a/plugins/mod_announce.lua
+++ b/plugins/mod_announce.lua
@@ -38,6 +38,7 @@ end
-- Old <message>-based jabberd-style announcement sending
function handle_announcement(event)
local stanza = event.stanza;
+ -- luacheck: ignore 211/node
local node, host, resource = jid.split(stanza.attr.to);
if resource ~= "announce/online" then
diff --git a/plugins/mod_auth_anonymous.lua b/plugins/mod_auth_anonymous.lua
index 1f2bceb3..90646e71 100644
--- a/plugins/mod_auth_anonymous.lua
+++ b/plugins/mod_auth_anonymous.lua
@@ -11,6 +11,8 @@ local new_sasl = require "util.sasl".new;
local datamanager = require "util.datamanager";
local hosts = prosody.hosts;
+local allow_storage = module:get_option_boolean("allow_anonymous_storage", false);
+
-- define auth provider
local provider = {};
@@ -62,10 +64,14 @@ if not module:get_option_boolean("allow_anonymous_s2s", false) then
end
function module.load()
- datamanager.add_callback(dm_callback);
+ if not allow_storage then
+ datamanager.add_callback(dm_callback);
+ end
end
function module.unload()
- datamanager.remove_callback(dm_callback);
+ if not allow_storage then
+ datamanager.remove_callback(dm_callback);
+ end
end
module:provides("auth", provider);
diff --git a/plugins/mod_auth_cyrus.lua b/plugins/mod_auth_cyrus.lua
deleted file mode 100644
index 0debc287..00000000
--- a/plugins/mod_auth_cyrus.lua
+++ /dev/null
@@ -1,85 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
--- luacheck: ignore 212
-
-local log = require "util.logger".init("auth_cyrus");
-
-local usermanager_user_exists = require "core.usermanager".user_exists;
-
-local cyrus_service_realm = module:get_option("cyrus_service_realm");
-local cyrus_service_name = module:get_option("cyrus_service_name");
-local cyrus_application_name = module:get_option("cyrus_application_name");
-local require_provisioning = module:get_option("cyrus_require_provisioning") or false;
-local host_fqdn = module:get_option("cyrus_server_fqdn");
-
-prosody.unlock_globals(); --FIXME: Figure out why this is needed and
- -- why cyrussasl isn't caught by the sandbox
-local cyrus_new = require "util.sasl_cyrus".new;
-prosody.lock_globals();
-local new_sasl = function(realm)
- return cyrus_new(
- cyrus_service_realm or realm,
- cyrus_service_name or "xmpp",
- cyrus_application_name or "prosody",
- host_fqdn
- );
-end
-
-do -- diagnostic
- local list;
- for mechanism in pairs(new_sasl(module.host):mechanisms()) do
- list = (not(list) and mechanism) or (list..", "..mechanism);
- end
- if not list then
- module:log("error", "No Cyrus SASL mechanisms available");
- else
- module:log("debug", "Available Cyrus SASL mechanisms: %s", list);
- end
-end
-
-local host = module.host;
-
--- define auth provider
-local provider = {};
-log("debug", "initializing default authentication provider for host '%s'", host);
-
-function provider.test_password(username, password)
- return nil, "Legacy auth not supported with Cyrus SASL.";
-end
-
-function provider.get_password(username)
- return nil, "Passwords unavailable for Cyrus SASL.";
-end
-
-function provider.set_password(username, password)
- return nil, "Passwords unavailable for Cyrus SASL.";
-end
-
-function provider.user_exists(username)
- if require_provisioning then
- return usermanager_user_exists(username, host);
- end
- return true;
-end
-
-function provider.create_user(username, password)
- return nil, "Account creation/modification not available with Cyrus SASL.";
-end
-
-function provider.get_sasl_handler()
- local handler = new_sasl(host);
- if require_provisioning then
- function handler.require_provisioning(username)
- return usermanager_user_exists(username, host);
- end
- end
- return handler;
-end
-
-module:provides("auth", provider);
-
diff --git a/plugins/mod_auth_internal_hashed.lua b/plugins/mod_auth_internal_hashed.lua
index b29a9ee8..1b0e76ed 100644
--- a/plugins/mod_auth_internal_hashed.lua
+++ b/plugins/mod_auth_internal_hashed.lua
@@ -9,7 +9,7 @@
local max = math.max;
-local getAuthenticationDatabaseSHA1 = require "util.sasl.scram".getAuthenticationDatabaseSHA1;
+local scram_hashers = require "util.sasl.scram".hashers;
local usermanager = require "core.usermanager";
local generate_uuid = require "util.uuid".generate;
local new_sasl = require "util.sasl".new;
@@ -23,10 +23,12 @@ local host = module.host;
local accounts = module:open_store("accounts");
-
+local hash_name = module:get_option_string("password_hash", "SHA-1");
+local get_auth_db = assert(scram_hashers[hash_name], "SCRAM-"..hash_name.." not supported by SASL library");
+local scram_name = "scram_"..hash_name:gsub("%-","_"):lower();
-- Default; can be set per-user
-local default_iteration_count = 4096;
+local default_iteration_count = module:get_option_number("default_iteration_count", 10000);
-- define auth provider
local provider = {};
@@ -55,7 +57,7 @@ function provider.test_password(username, password)
return nil, "Auth failed. Stored salt and iteration count information is not complete.";
end
- local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, credentials.salt, credentials.iteration_count);
+ local valid, stored_key, server_key = get_auth_db(password, credentials.salt, credentials.iteration_count);
local stored_key_hex = to_hex(stored_key);
local server_key_hex = to_hex(server_key);
@@ -73,7 +75,7 @@ function provider.set_password(username, password)
if account then
account.salt = generate_uuid();
account.iteration_count = max(account.iteration_count or 0, default_iteration_count);
- local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, account.salt, account.iteration_count);
+ local valid, stored_key, server_key = get_auth_db(password, account.salt, account.iteration_count);
if not valid then
return valid, stored_key;
end
@@ -107,7 +109,7 @@ function provider.create_user(username, password)
return accounts:set(username, {});
end
local salt = generate_uuid();
- local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, salt, default_iteration_count);
+ local valid, stored_key, server_key = get_auth_db(password, salt, default_iteration_count);
if not valid then
return valid, stored_key;
end
@@ -128,7 +130,7 @@ function provider.get_sasl_handler()
plain_test = function(_, username, password, realm)
return usermanager.test_password(username, realm, password), true;
end,
- scram_sha_1 = function(_, username)
+ [scram_name] = function(_, username)
local credentials = accounts:get(username);
if not credentials then return; end
if credentials.password then
diff --git a/plugins/mod_auth_ldap.lua b/plugins/mod_auth_ldap.lua
new file mode 100644
index 00000000..4d484aaa
--- /dev/null
+++ b/plugins/mod_auth_ldap.lua
@@ -0,0 +1,154 @@
+-- mod_auth_ldap
+
+local jid_split = require "util.jid".split;
+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", "subtree");
+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 ldap_admins = module:get_option_string("ldap_admin_filter",
+ module:get_option_string("ldap_admins")); -- COMPAT with mistake in documentation
+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
+
+ -- luacheck: ignore 411/success
+ 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 _=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
+
+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) -- luacheck: ignore 212
+ 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) -- luacheck: ignore 212/sasl
+ 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)
+ local ok, err = lualdap.open_simple(ldap_server, userdn, password, ldap_tls);
+ if not ok then
+ module:log("debug", "ldap open_simple error: %s", err);
+ end
+ return not not ok;
+ 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) -- luacheck: ignore 212/sasl
+ return provider.test_password(username, password), true;
+ end
+ });
+ end
+else
+ module:log("error", "Unsupported ldap_mode %s", tostring(ldap_mode));
+end
+
+if ldap_admins then
+ function provider.is_admin(jid)
+ local username, user_host = jid_split(jid);
+ if user_host ~= module.host then
+ return false;
+ end
+ return ldap_do("search", 2, {
+ base = ldap_base;
+ scope = ldap_scope;
+ sizelimit = 1;
+ filter = ldap_admins:gsub("%$(%a+)", {
+ user = ldap_filter_escape(username);
+ host = host;
+ });
+ });
+ end
+end
+
+module:provides("auth", provider);
diff --git a/plugins/mod_authz_internal.lua b/plugins/mod_authz_internal.lua
new file mode 100644
index 00000000..17687959
--- /dev/null
+++ b/plugins/mod_authz_internal.lua
@@ -0,0 +1,59 @@
+local array = require "util.array";
+local it = require "util.iterators";
+local set = require "util.set";
+local jid_split = require "util.jid".split;
+local normalize = require "util.jid".prep;
+local config_admin_jids = module:get_option_inherited_set("admins", {}) / normalize;
+local host = module.host;
+local role_store = module:open_store("roles");
+local role_map_store = module:open_store("roles", "map");
+
+local admin_role = { ["prosody:admin"] = true };
+
+function get_user_roles(user)
+ if config_admin_jids:contains(user.."@"..host) then
+ return admin_role;
+ end
+ return role_store:get(user);
+end
+
+function set_user_roles(user, roles)
+ role_store:set(user, roles)
+ return true;
+end
+
+function get_users_with_role(role)
+ local storage_role_users = it.to_array(it.keys(role_map_store:get_all(role) or {}));
+ if role == "prosody:admin" then
+ local config_admin_users = config_admin_jids / function (admin_jid)
+ local j_node, j_host = jid_split(admin_jid);
+ if j_host == host then
+ return j_node;
+ end
+ end;
+ return it.to_array(config_admin_users + set.new(storage_role_users));
+ end
+ return storage_role_users;
+end
+
+function get_jid_roles(jid)
+ if config_admin_jids:contains(jid) then
+ return admin_role;
+ end
+ return nil;
+end
+
+function set_jid_roles(jid) -- luacheck: ignore 212
+ return false;
+end
+
+function get_jids_with_role(role)
+ -- Fetch role users from storage
+ local storage_role_jids = array.map(get_users_with_role(role), function (username)
+ return username.."@"..host;
+ end);
+ if role == "prosody:admin" then
+ return it.to_array(config_admin_jids + set.new(storage_role_jids));
+ end
+ return storage_role_jids;
+end
diff --git a/plugins/mod_blocklist.lua b/plugins/mod_blocklist.lua
index ee48ffad..dad06b62 100644
--- a/plugins/mod_blocklist.lua
+++ b/plugins/mod_blocklist.lua
@@ -67,7 +67,7 @@ local function migrate_privacy_list(username)
if item.type == "jid" and item.action == "deny" then
local jid = jid_prep(item.value);
if not jid then
- module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, tostring(item.value));
+ module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, item.value);
else
migrated_data[jid] = true;
end
@@ -162,7 +162,7 @@ local function edit_blocklist(event)
local blocklist = cache[username] or get_blocklist(username);
local new_blocklist = {
- -- We set the [false] key to someting as a signal not to migrate privacy lists
+ -- We set the [false] key to something as a signal not to migrate privacy lists
[false] = blocklist[false] or { created = now; };
};
if type(blocklist[false]) == "table" then
@@ -189,6 +189,7 @@ local function edit_blocklist(event)
if is_blocking then
for jid in pairs(send_unavailable) do
+ -- Check that this JID isn't already blocked, i.e. this is not a change
if not blocklist[jid] then
for _, session in pairs(sessions[username].sessions) do
if session.presence then
diff --git a/plugins/mod_bookmarks.lua b/plugins/mod_bookmarks.lua
new file mode 100644
index 00000000..ad5f459e
--- /dev/null
+++ b/plugins/mod_bookmarks.lua
@@ -0,0 +1,481 @@
+local mm = require "core.modulemanager";
+if mm.get_modules_for_host(module.host):contains("bookmarks2") then
+ error("mod_bookmarks and mod_bookmarks2 are conflicting, please disable one of them.", 0);
+end
+
+local st = require "util.stanza";
+local jid_split = require "util.jid".split;
+
+local mod_pep = module:depends "pep";
+local private_storage = module:open_store("private", "map");
+
+local namespace = "urn:xmpp:bookmarks:1";
+local namespace_old = "urn:xmpp:bookmarks:0";
+local namespace_private = "jabber:iq:private";
+local namespace_legacy = "storage:bookmarks";
+local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
+
+local default_options = {
+ ["persist_items"] = true;
+ ["max_items"] = "max";
+ ["send_last_published_item"] = "never";
+ ["access_model"] = "whitelist";
+};
+
+module:hook("account-disco-info", function (event)
+ -- This Time it’s Serious!
+ event.reply:tag("feature", { var = namespace.."#compat" }):up();
+ event.reply:tag("feature", { var = namespace.."#compat-pep" }):up();
+
+ -- COMPAT XEP-0411
+ event.reply:tag("feature", { var = "urn:xmpp:bookmarks-conversion:0" }):up();
+end);
+
+-- This must be declared on the domain JID, not the account JID. Note that
+-- this isn’t defined in the XEP.
+module:add_feature(namespace_private);
+
+
+local function generate_legacy_storage(items)
+ local storage = st.stanza("storage", { xmlns = namespace_legacy });
+ for _, item_id in ipairs(items) do
+ local item = items[item_id];
+ local bookmark = item:get_child("conference", namespace);
+ if not bookmark then
+ module:log("warn", "Invalid bookmark published: expected {%s}conference, got {%s}%s", namespace,
+
+ item.tags[1] and item.tags[1].attr.xmlns, item.tags[1] and item.tags[1].name);
+ end
+ local conference = st.stanza("conference", {
+ jid = item.attr.id,
+ name = bookmark and bookmark.attr.name,
+ autojoin = bookmark and bookmark.attr.autojoin,
+ });
+ local nick = bookmark and bookmark:get_child_text("nick");
+ if nick ~= nil then
+ conference:text_tag("nick", nick):up();
+ end
+ local password = bookmark and bookmark:get_child_text("password");
+ if password ~= nil then
+ conference:text_tag("password", password):up();
+ end
+ storage:add_child(conference);
+ end
+
+ return storage;
+end
+
+local function on_retrieve_legacy_pep(event)
+ local stanza, session = event.stanza, event.origin;
+ local pubsub = stanza:get_child("pubsub", "http://jabber.org/protocol/pubsub");
+ if pubsub == nil then
+ return;
+ end
+
+ local items = pubsub:get_child("items");
+ if items == nil then
+ return;
+ end
+
+ local node = items.attr.node;
+ if node ~= namespace_legacy then
+ return;
+ end
+
+ local username = session.username;
+ local jid = username.."@"..session.host;
+ local service = mod_pep.get_pep_service(username);
+ local ok, ret = service:get_items(namespace, session.full_jid);
+ if not ok then
+ module:log("error", "Failed to retrieve PEP bookmarks of %s: %s", jid, ret);
+ session.send(st.error_reply(stanza, "cancel", ret, "Failed to retrive bookmarks from PEP"));
+ return true;
+ end
+
+ local storage = generate_legacy_storage(ret);
+
+ module:log("debug", "Sending back legacy PEP for %s: %s", jid, storage);
+ session.send(st.reply(stanza)
+ :tag("pubsub", {xmlns = "http://jabber.org/protocol/pubsub"})
+ :tag("items", {node = namespace_legacy})
+ :tag("item", {id = "current"})
+ :add_child(storage));
+ return true;
+end
+
+local function on_retrieve_private_xml(event)
+ local stanza, session = event.stanza, event.origin;
+ local query = stanza:get_child("query", namespace_private);
+ if query == nil then
+ return;
+ end
+
+ local bookmarks = query:get_child("storage", namespace_legacy);
+ if bookmarks == nil then
+ return;
+ end
+
+ module:log("debug", "Getting private bookmarks: %s", bookmarks);
+
+ local username = session.username;
+ local jid = username.."@"..session.host;
+ local service = mod_pep.get_pep_service(username);
+ local ok, ret = service:get_items(namespace, session.full_jid);
+ if not ok then
+ if ret == "item-not-found" then
+ module:log("debug", "Got no PEP bookmarks item for %s, returning empty private bookmarks", jid);
+ session.send(st.reply(stanza):add_child(query));
+ else
+ module:log("error", "Failed to retrieve PEP bookmarks of %s: %s", jid, ret);
+ session.send(st.error_reply(stanza, "cancel", ret, "Failed to retrive bookmarks from PEP"));
+ end
+ return true;
+ end
+
+ local storage = generate_legacy_storage(ret);
+
+ module:log("debug", "Sending back private for %s: %s", jid, storage);
+ session.send(st.reply(stanza):query(namespace_private):add_child(storage));
+ return true;
+end
+
+local function compare_bookmark2(a, b)
+ if a == nil or b == nil then
+ return false;
+ end
+ local a_conference = a:get_child("conference", namespace);
+ local b_conference = b:get_child("conference", namespace);
+ local a_nick = a_conference:get_child_text("nick");
+ local b_nick = b_conference:get_child_text("nick");
+ local a_password = a_conference:get_child_text("password");
+ local b_password = b_conference:get_child_text("password");
+ return (a.attr.id == b.attr.id and
+ a_conference.attr.name == b_conference.attr.name and
+ a_conference.attr.autojoin == b_conference.attr.autojoin and
+ a_nick == b_nick and
+ a_password == b_password);
+end
+
+local function publish_to_pep(jid, bookmarks, synchronise)
+ local service = mod_pep.get_pep_service(jid_split(jid));
+
+ if #bookmarks.tags == 0 then
+ if synchronise then
+ -- If we set zero legacy bookmarks, purge the bookmarks 2 node.
+ module:log("debug", "No bookmark in the set, purging instead.");
+ return service:purge(namespace, jid, true);
+ else
+ return true;
+ end
+ end
+
+ -- Retrieve the current bookmarks2.
+ module:log("debug", "Retrieving the current bookmarks 2.");
+ local has_bookmarks2, ret = service:get_items(namespace, jid);
+ local bookmarks2;
+ if not has_bookmarks2 and ret == "item-not-found" then
+ module:log("debug", "Got item-not-found, assuming it was empty until now, creating.");
+ local ok, err = service:create(namespace, jid, default_options);
+ if not ok then
+ module:log("error", "Creating bookmarks 2 node failed: %s", err);
+ return ok, err;
+ end
+ bookmarks2 = {};
+ elseif not has_bookmarks2 then
+ module:log("debug", "Got %s error, aborting.", ret);
+ return false, ret;
+ else
+ module:log("debug", "Got existing bookmarks2.");
+ bookmarks2 = ret;
+
+ local ok, err = service:get_node_config(namespace, jid);
+ if not ok then
+ module:log("error", "Retrieving bookmarks 2 node config failed: %s", err);
+ return ok, err;
+ end
+
+ local options = err;
+ for key, value in pairs(default_options) do
+ if options[key] and options[key] ~= value then
+ module:log("warn", "Overriding bookmarks 2 configuration for %s, from %s to %s", jid, options[key], value);
+ options[key] = value;
+ end
+ end
+
+ local ok, err = service:set_node_config(namespace, jid, options);
+ if not ok then
+ module:log("error", "Setting bookmarks 2 node config failed: %s", err);
+ return ok, err;
+ end
+ end
+
+ -- Get a list of all items we may want to remove.
+ local to_remove = {};
+ for i in ipairs(bookmarks2) do
+ to_remove[bookmarks2[i]] = true;
+ end
+
+ for bookmark in bookmarks:childtags("conference", namespace_legacy) do
+ -- Create the new conference element by copying everything from the legacy one.
+ local conference = st.stanza("conference", {
+ xmlns = namespace,
+ name = bookmark.attr.name,
+ autojoin = bookmark.attr.autojoin,
+ });
+ local nick = bookmark:get_child_text("nick");
+ if nick ~= nil then
+ conference:text_tag("nick", nick):up();
+ end
+ local password = bookmark:get_child_text("password");
+ if password ~= nil then
+ conference:text_tag("password", password):up();
+ end
+
+ -- Create its wrapper.
+ local item = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = bookmark.attr.jid })
+ :add_child(conference);
+
+ -- Then publish it only if it’s a new one or updating a previous one.
+ if compare_bookmark2(item, bookmarks2[bookmark.attr.jid]) then
+ module:log("debug", "Item %s identical to the previous one, skipping.", item.attr.id);
+ to_remove[bookmark.attr.jid] = nil;
+ else
+ if bookmarks2[bookmark.attr.jid] == nil then
+ module:log("debug", "Item %s not existing previously, publishing.", item.attr.id);
+ else
+ module:log("debug", "Item %s different from the previous one, publishing.", item.attr.id);
+ to_remove[bookmark.attr.jid] = nil;
+ end
+ local ok, err = service:publish(namespace, jid, bookmark.attr.jid, item, default_options);
+ if not ok then
+ module:log("error", "Publishing item %s failed: %s", item.attr.id, err);
+ return ok, err;
+ end
+ end
+ end
+
+ -- Now handle retracting items that have been removed.
+ if synchronise then
+ for id in pairs(to_remove) do
+ module:log("debug", "Item %s removed from bookmarks.", id);
+ local ok, err = service:retract(namespace, jid, id, st.stanza("retract", { id = id }));
+ if not ok then
+ module:log("error", "Retracting item %s failed: %s", id, err);
+ return ok, err;
+ end
+ end
+ end
+ return true;
+end
+
+-- Synchronise legacy PEP to PEP.
+local function on_publish_legacy_pep(event)
+ local stanza, session = event.stanza, event.origin;
+ local pubsub = stanza:get_child("pubsub", "http://jabber.org/protocol/pubsub");
+ if pubsub == nil then
+ return;
+ end
+
+ local publish = pubsub:get_child("publish");
+ if publish == nil then return end
+ if publish.attr.node == namespace_old then
+ session.send(st.error_reply(stanza, "modify", "not-allowed",
+ "Your client does XEP-0402 version 0.3.0 but 0.4.0+ is required"));
+ return true;
+ end
+ if publish.attr.node ~= namespace_legacy then
+ return;
+ end
+
+ local item = publish:get_child("item");
+ if item == nil then
+ return;
+ end
+
+ -- Here we ignore the item id, it’ll be generated as 'current' anyway.
+
+ local bookmarks = item:get_child("storage", namespace_legacy);
+ if bookmarks == nil then
+ return;
+ end
+
+ -- We also ignore the publish-options.
+
+ module:log("debug", "Legacy PEP bookmarks set by client, publishing to PEP.");
+
+ local ok, err = publish_to_pep(session.full_jid, bookmarks, true);
+ if not ok then
+ module:log("error", "Failed to publish to PEP bookmarks for %s@%s: %s", session.username, session.host, err);
+ session.send(st.error_reply(stanza, "cancel", "internal-server-error", "Failed to store bookmarks to PEP"));
+ return true;
+ end
+
+ session.send(st.reply(stanza));
+ return true;
+end
+
+-- Synchronise Private XML to PEP.
+local function on_publish_private_xml(event)
+ local stanza, session = event.stanza, event.origin;
+ local query = stanza:get_child("query", namespace_private);
+ if query == nil then
+ return;
+ end
+
+ local bookmarks = query:get_child("storage", namespace_legacy);
+ if bookmarks == nil then
+ return;
+ end
+
+ module:log("debug", "Private bookmarks set by client, publishing to PEP.");
+
+ local ok, err = publish_to_pep(session.full_jid, bookmarks, true);
+ if not ok then
+ module:log("error", "Failed to publish to PEP bookmarks for %s@%s: %s", session.username, session.host, err);
+ session.send(st.error_reply(stanza, "cancel", "internal-server-error", "Failed to store bookmarks to PEP"));
+ return true;
+ end
+
+ session.send(st.reply(stanza));
+ return true;
+end
+
+local function migrate_legacy_bookmarks(event)
+ local session = event.session;
+ local username = session.username;
+ local service = mod_pep.get_pep_service(username);
+ local jid = username.."@"..session.host;
+
+ local ok, ret = service:get_items(namespace_legacy, session.full_jid);
+ if ok and ret[1] then
+ module:log("debug", "Legacy PEP bookmarks found for %s, migrating.", jid);
+ local failed = false;
+ for _, item_id in ipairs(ret) do
+ local item = ret[item_id];
+ if item.attr.id ~= "current" then
+ module:log("warn", "Legacy PEP bookmarks for %s isn’t using 'current' as its id: %s", jid, item.attr.id);
+ end
+ local bookmarks = item:get_child("storage", namespace_legacy);
+ module:log("debug", "Got legacy PEP bookmarks of %s: %s", jid, bookmarks);
+
+ local ok, err = publish_to_pep(session.full_jid, bookmarks, false);
+ if not ok then
+ module:log("error", "Failed to store legacy PEP bookmarks to bookmarks 2 for %s, aborting migration: %s", jid, err);
+ failed = true;
+ break;
+ end
+ end
+ if not failed then
+ module:log("debug", "Successfully migrated legacy PEP bookmarks of %s to bookmarks 2, clearing items.", jid);
+ local ok, err = service:purge(namespace_legacy, jid, false);
+ if not ok then
+ module:log("error", "Failed to delete legacy PEP bookmarks for %s: %s", jid, err);
+ end
+ end
+ end
+
+ local ok, current_legacy_config = service:get_node_config(namespace_legacy, jid);
+ if not ok or current_legacy_config["access_model"] ~= "whitelist" then
+ -- The legacy node must exist in order for the access model to apply to the
+ -- XEP-0411 COMPAT broadcasts (which bypass the pubsub service entirely),
+ -- so create or reconfigure it to be useless.
+ --
+ -- FIXME It would be handy to have a publish model that prevents the owner
+ -- from publishing, but the affiliation takes priority
+ local config = {
+ ["persist_items"] = false;
+ ["max_items"] = 1;
+ ["send_last_published_item"] = "never";
+ ["access_model"] = "whitelist";
+ };
+ local ok, err;
+ if ret == "item-not-found" then
+ ok, err = service:create(namespace_legacy, jid, config);
+ else
+ ok, err = service:set_node_config(namespace_legacy, jid, config);
+ end
+ if not ok then
+ module:log("error", "Setting legacy bookmarks node config failed: %s", err);
+ return ok, err;
+ end
+ end
+
+ local data, err = private_storage:get(username, "storage:storage:bookmarks");
+ if not data then
+ module:log("debug", "No existing legacy bookmarks for %s, migration already done: %s", jid, err);
+ local ok, ret2 = service:get_items(namespace, session.full_jid);
+ if not ok or not ret2 then
+ module:log("debug", "Additionally, no bookmarks 2 were existing for %s, assuming empty.", jid);
+ module:fire_event("bookmarks/empty", { session = session });
+ end
+ return;
+ end
+ local bookmarks = st.deserialize(data);
+ module:log("debug", "Got legacy bookmarks of %s: %s", jid, bookmarks);
+
+ module:log("debug", "Going to store legacy bookmarks to bookmarks 2 %s.", jid);
+ local ok, err = publish_to_pep(session.full_jid, bookmarks, false);
+ if not ok then
+ module:log("error", "Failed to store legacy bookmarks to bookmarks 2 for %s, aborting migration: %s", jid, err);
+ return;
+ end
+ module:log("debug", "Stored legacy bookmarks to bookmarks 2 for %s.", jid);
+
+ local ok, err = private_storage:set(username, "storage:storage:bookmarks", nil);
+ if not ok then
+ module:log("error", "Failed to remove legacy bookmarks of %s: %s", jid, err);
+ return;
+ end
+ module:log("debug", "Removed legacy bookmarks of %s, migration done!", jid);
+end
+
+module:hook("iq/bare/jabber:iq:private:query", function (event)
+ if event.stanza.attr.type == "get" then
+ return on_retrieve_private_xml(event);
+ else
+ return on_publish_private_xml(event);
+ end
+end, 1);
+module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function (event)
+ if event.stanza.attr.type == "get" then
+ return on_retrieve_legacy_pep(event);
+ else
+ return on_publish_legacy_pep(event);
+ end
+end, 1);
+if module:get_option_boolean("upgrade_legacy_bookmarks", true) then
+ module:hook("resource-bind", migrate_legacy_bookmarks);
+end
+-- COMPAT XEP-0411 Broadcast as per XEP-0048 + PEP
+local function legacy_broadcast(event)
+ local service = event.service;
+ local ok, bookmarks = service:get_items(namespace, event.actor);
+ if bookmarks == "item-not-found" then ok, bookmarks = true, {} end
+ if not ok then return end
+ local legacy_bookmarks_item = st.stanza("item", { xmlns = xmlns_pubsub; id = "current" })
+ :add_child(generate_legacy_storage(bookmarks));
+ service:broadcast("items", namespace_legacy, { --[[ no subscribers ]] }, legacy_bookmarks_item, event.actor);
+end
+local function broadcast_legacy_removal(event)
+ if event.node ~= namespace then return end
+ return legacy_broadcast(event);
+end
+module:hook("presence/initial", function (event)
+ -- Broadcasts to all clients with legacy+notify, not just the one coming online.
+ -- Upgrade to XEP-0402 to avoid it
+ local service = mod_pep.get_pep_service(event.origin.username);
+ legacy_broadcast({ service = service, actor = event.origin.full_jid });
+end);
+module:handle_items("pep-service", function (event)
+ local service = event.item.service;
+ module:hook_object_event(service.events, "item-published/" .. namespace, legacy_broadcast);
+ module:hook_object_event(service.events, "item-retracted", broadcast_legacy_removal);
+ module:hook_object_event(service.events, "node-purged", broadcast_legacy_removal);
+ module:hook_object_event(service.events, "node-deleted", broadcast_legacy_removal);
+end, function (event)
+ local service = event.item.service;
+ module:unhook_object_event(service.events, "item-published/" .. namespace, legacy_broadcast);
+ module:unhook_object_event(service.events, "item-retracted", broadcast_legacy_removal);
+ module:unhook_object_event(service.events, "node-purged", broadcast_legacy_removal);
+ module:unhook_object_event(service.events, "node-deleted", broadcast_legacy_removal);
+end, true);
diff --git a/plugins/mod_bosh.lua b/plugins/mod_bosh.lua
index db7ae03e..ceb31a9f 100644
--- a/plugins/mod_bosh.lua
+++ b/plugins/mod_bosh.lua
@@ -44,20 +44,42 @@ local bosh_max_polling = module:get_option_number("bosh_max_polling", 5);
local bosh_max_wait = module:get_option_number("bosh_max_wait", 120);
local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure");
-local cross_domain = module:get_option("cross_domain_bosh", false);
+local cross_domain = module:get_option("cross_domain_bosh");
local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024*256);
-if cross_domain == true then cross_domain = "*"; end
-if type(cross_domain) == "table" then cross_domain = table.concat(cross_domain, ", "); end
+if cross_domain ~= nil then
+ module:log("info", "The 'cross_domain_bosh' option has been deprecated");
+end
local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
-- All sessions, and sessions that have no requests open
local sessions = module:shared("sessions");
+local measure_active = module:measure("active_sessions", "amount");
+local measure_inactive = module:measure("inactive_sessions", "amount");
+local report_bad_host = module:measure("bad_host", "rate");
+local report_bad_sid = module:measure("bad_sid", "rate");
+local report_new_sid = module:measure("new_sid", "rate");
+local report_timeout = module:measure("timeout", "rate");
+
+module:hook("stats-update", function ()
+ local active = 0;
+ local inactive = 0;
+ for _, session in pairs(sessions) do
+ if #session.requests > 0 then
+ active = active + 1;
+ else
+ inactive = inactive + 1;
+ end
+ end
+ measure_active(active);
+ measure_inactive(inactive);
+end);
+
-- Used to respond to idle sessions (those with waiting requests)
function on_destroy_request(request)
- log("debug", "Request destroyed: %s", tostring(request));
+ log("debug", "Request destroyed: %s", request);
local session = sessions[request.context.sid];
if session then
local requests = session.requests;
@@ -74,7 +96,7 @@ function on_destroy_request(request)
if session.inactive_timer then
session.inactive_timer:stop();
end
- session.inactive_timer = module:add_timer(max_inactive, check_inactive, session, request.context,
+ session.inactive_timer = module:add_timer(max_inactive, session_timeout, session, request.context,
"BOSH client silent for over "..max_inactive.." seconds");
(session.log or log)("debug", "BOSH session marked as inactive (for %ds)", max_inactive);
end
@@ -85,31 +107,16 @@ function on_destroy_request(request)
end
end
-function check_inactive(now, session, context, reason) -- luacheck: ignore 212/now
+function session_timeout(now, session, context, reason) -- luacheck: ignore 212/now
if not session.destroyed then
+ report_timeout();
sessions[context.sid] = nil;
sm_destroy_session(session, reason);
end
end
-local function set_cross_domain_headers(response)
- local headers = response.headers;
- headers.access_control_allow_methods = "GET, POST, OPTIONS";
- headers.access_control_allow_headers = "Content-Type";
- headers.access_control_max_age = "7200";
- headers.access_control_allow_origin = cross_domain;
- return response;
-end
-
-function handle_OPTIONS(event)
- if cross_domain and event.request.headers.origin then
- set_cross_domain_headers(event.response);
- end
- return "";
-end
-
function handle_POST(event)
- log("debug", "Handling new request %s: %s\n----------", tostring(event.request), tostring(event.request.body));
+ log("debug", "Handling new request %s: %s\n----------", event.request, event.request.body);
local request, response = event.request, event.response;
response.on_destroy = on_destroy_request;
@@ -122,10 +129,6 @@ function handle_POST(event)
local headers = response.headers;
headers.content_type = "text/xml; charset=utf-8";
- if cross_domain and request.headers.origin then
- set_cross_domain_headers(response);
- end
-
-- stream:feed() calls the stream_callbacks, so all stanzas in
-- the body are processed in this next line before it returns.
-- In particular, the streamopened() stream callback is where
@@ -206,6 +209,7 @@ function handle_POST(event)
return;
end
module:log("warn", "Unable to associate request with a session (incomplete request?)");
+ report_bad_sid();
local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
["xmlns:stream"] = xmlns_streams, condition = "item-not-found" });
return tostring(close_reply) .. "\n";
@@ -221,7 +225,7 @@ local function bosh_reset_stream(session) session.notopen = true; end
local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" };
local function bosh_close_stream(session, reason)
- (session.log or log)("info", "BOSH client disconnected: %s", tostring((reason and reason.condition or reason) or "session close"));
+ (session.log or log)("info", "BOSH client disconnected: %s", (reason and reason.condition or reason) or "session close");
local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
["xmlns:stream"] = xmlns_streams });
@@ -232,6 +236,8 @@ local function bosh_close_stream(session, reason)
if type(reason) == "string" then -- assume stream error
close_reply:tag("stream:error")
:tag(reason, {xmlns = xmlns_xmpp_streams});
+ elseif st.is_stanza(reason) then
+ close_reply = reason;
elseif type(reason) == "table" then
if reason.condition then
close_reply:tag("stream:error")
@@ -242,11 +248,9 @@ local function bosh_close_stream(session, reason)
if reason.extra then
close_reply:add_child(reason.extra);
end
- elseif reason.name then -- a stanza
- close_reply = reason;
end
end
- log("info", "Disconnecting client, <stream:error> is: %s", tostring(close_reply));
+ log("info", "Disconnecting client, <stream:error> is: %s", close_reply);
end
local response_body = tostring(close_reply);
@@ -269,9 +273,19 @@ function stream_callbacks.streamopened(context, attr)
-- New session request
context.notopen = nil; -- Signals that we accept this opening tag
+ if not attr.to then
+ log("debug", "BOSH client tried to connect without specifying a host");
+ report_bad_host();
+ local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
+ ["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
+ response:send(tostring(close_reply));
+ return;
+ end
+
local to_host = nameprep(attr.to);
if not to_host then
- log("debug", "BOSH client tried to connect to invalid host: %s", tostring(attr.to));
+ log("debug", "BOSH client tried to connect to invalid host: %s", attr.to);
+ report_bad_host();
local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
response:send(tostring(close_reply));
@@ -279,7 +293,8 @@ function stream_callbacks.streamopened(context, attr)
end
if not prosody.hosts[to_host] then
- log("debug", "BOSH client tried to connect to non-existant host: %s", attr.to);
+ log("debug", "BOSH client tried to connect to non-existent host: %s", attr.to);
+ report_bad_host();
local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
response:send(tostring(close_reply));
@@ -288,6 +303,7 @@ function stream_callbacks.streamopened(context, attr)
if prosody.hosts[to_host].type ~= "local" then
log("debug", "BOSH client tried to connect to %s host: %s", prosody.hosts[to_host].type, attr.to);
+ report_bad_host();
local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
response:send(tostring(close_reply));
@@ -296,7 +312,7 @@ function stream_callbacks.streamopened(context, attr)
local wait = tonumber(attr.wait);
if not rid or (not attr.wait or not wait or wait < 0 or wait % 1 ~= 0) then
- log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", tostring(attr.rid), tostring(attr.wait));
+ log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", attr.rid, attr.wait);
local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
["xmlns:stream"] = xmlns_streams, condition = "bad-request" });
response:send(tostring(close_reply));
@@ -307,6 +323,7 @@ function stream_callbacks.streamopened(context, attr)
-- New session
sid = new_uuid();
+ -- TODO use util.session
local session = {
type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to,
rid = rid - 1, -- Hack for initial session setup, "previous" rid was $current_request - 1
@@ -327,6 +344,7 @@ function stream_callbacks.streamopened(context, attr)
session.log("debug", "BOSH session created for request from %s", session.ip);
log("info", "New BOSH session, assigned it sid '%s'", sid);
+ report_new_sid();
module:fire_event("bosh-session", { session = session, request = request });
@@ -341,7 +359,7 @@ function stream_callbacks.streamopened(context, attr)
s.attr.xmlns = "jabber:client";
end
s = filter("stanzas/out", s);
- --log("debug", "Sending BOSH data: %s", tostring(s));
+ --log("debug", "Sending BOSH data: %s", s);
if not s then return true end
t_insert(session.send_buffer, tostring(s));
@@ -381,6 +399,7 @@ function stream_callbacks.streamopened(context, attr)
if not session then
-- Unknown sid
log("info", "Client tried to use sid '%s' which we don't know about", sid);
+ report_bad_sid();
response:send(tostring(st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", condition = "item-not-found" })));
context.notopen = nil;
return;
@@ -443,7 +462,7 @@ function stream_callbacks.streamopened(context, attr)
end
end
-local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(tostring(err), 2)); end
+local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(err, 2)); end
function runner_callbacks:error(err) -- luacheck: ignore 212/self
return handleerr(err);
@@ -513,25 +532,28 @@ function stream_callbacks.error(context, error)
end
end
-local GET_response = {
- headers = {
- content_type = "text/html";
- };
- body = [[<html><body>
- <p>It works! Now point your BOSH client to this URL to connect to Prosody.</p>
- <p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p>
- </body></html>]];
-};
-
-module:depends("http");
-module:provides("http", {
- default_path = "/http-bind";
- route = {
- ["GET"] = GET_response;
- ["GET /"] = GET_response;
- ["OPTIONS"] = handle_OPTIONS;
- ["OPTIONS /"] = handle_OPTIONS;
- ["POST"] = handle_POST;
- ["POST /"] = handle_POST;
- };
-});
+local function GET_response(event)
+ return module:fire_event("http-message", {
+ response = event.response;
+ ---
+ title = "Prosody BOSH endpoint";
+ message = "It works! Now point your BOSH client to this URL to connect to Prosody.";
+ warning = not (consider_bosh_secure or event.request.secure) and "This endpoint is not considered secure!" or nil;
+ -- <p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p>
+ }) or "This is the Prosody BOSH endpoint.";
+end
+
+function module.add_host(module)
+ module:depends("http");
+ module:provides("http", {
+ default_path = "/http-bind";
+ route = {
+ ["GET"] = GET_response;
+ ["GET /"] = GET_response;
+ ["POST"] = handle_POST;
+ ["POST /"] = handle_POST;
+ };
+ });
+end
+
+module:add_host();
diff --git a/plugins/mod_c2s.lua b/plugins/mod_c2s.lua
index f9c2e9fb..c9aae3bc 100644
--- a/plugins/mod_c2s.lua
+++ b/plugins/mod_c2s.lua
@@ -12,6 +12,7 @@ local add_task = require "util.timer".add_task;
local new_xmpp_stream = require "util.xmppstream".new;
local nameprep = require "util.encodings".stringprep.nameprep;
local sessionmanager = require "core.sessionmanager";
+local statsmanager = require "core.statsmanager";
local st = require "util.stanza";
local sm_new_session, sm_destroy_session = sessionmanager.new_session, sessionmanager.destroy_session;
local uuid_generate = require "util.uuid".generate;
@@ -28,8 +29,7 @@ local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5);
local opt_keepalives = module:get_option_boolean("c2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024*256);
-local measure_connections = module:measure("connections", "amount");
-local measure_ipv6 = module:measure("ipv6", "amount");
+local measure_connections = module:metric("gauge", "connections", "", "Established c2s connections", {"host", "type", "ip_family"});
local sessions = module:shared("sessions");
local core_process_stanza = prosody.core_process_stanza;
@@ -39,24 +39,46 @@ local stream_callbacks = { default_ns = "jabber:client" };
local listener = {};
local runner_callbacks = {};
+local m_tls_params = module:metric(
+ "counter", "encrypted", "",
+ "Encrypted connections",
+ {"protocol"; "cipher"}
+);
+
module:hook("stats-update", function ()
- local count = 0;
- local ipv6 = 0;
+ -- for push backends, avoid sending out updates for each increment of
+ -- the metric below.
+ statsmanager.cork()
+ measure_connections:clear()
for _, session in pairs(sessions) do
- count = count + 1;
- if session.ip and session.ip:match(":") then
- ipv6 = ipv6 + 1;
- end
+ local host = session.host or ""
+ local type_ = session.type or "other"
+
+ -- we want to expose both v4 and v6 counters in all cases to make
+ -- queries smoother
+ local is_ipv6 = session.ip and session.ip:match(":") and 1 or 0
+ local is_ipv4 = 1 - is_ipv6
+ measure_connections:with_labels(host, type_, "ipv4"):add(is_ipv4)
+ measure_connections:with_labels(host, type_, "ipv6"):add(is_ipv6)
end
- measure_connections(count);
- measure_ipv6(ipv6);
+ statsmanager.uncork()
end);
--- Stream events handlers
local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'};
function stream_callbacks.streamopened(session, attr)
+ -- run _streamopened in async context
+ session.thread:run({ stream = "opened", attr = attr });
+end
+
+function stream_callbacks._streamopened(session, attr)
local send = session.send;
+ if not attr.to then
+ session:close{ condition = "improper-addressing",
+ text = "A 'to' attribute is required on stream headers" };
+ return;
+ end
local host = nameprep(attr.to);
if not host then
session:close{ condition = "improper-addressing",
@@ -80,7 +102,10 @@ function stream_callbacks.streamopened(session, attr)
return;
end
- session:open_stream();
+ session:open_stream(host, attr.from);
+
+ -- Opening the stream can cause the stream to be closed
+ if session.destroyed then return end
(session.log or log)("debug", "Sent reply <stream:stream> to client");
session.notopen = nil;
@@ -92,13 +117,13 @@ function stream_callbacks.streamopened(session, attr)
session.encrypted = true;
local sock = session.conn:socket();
- if sock.info then
- local info = sock:info();
+ local info = sock.info and sock:info();
+ if type(info) == "table" then
(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
session.compressed = info.compression;
+ m_tls_params:with_labels(info.protocol, info.cipher):add(1)
else
(session.log or log)("info", "Stream encrypted");
- session.compressed = sock.compression and sock:compression(); --COMPAT mw/luasec-hg
end
end
@@ -107,15 +132,23 @@ function stream_callbacks.streamopened(session, attr)
if features.tags[1] or session.full_jid then
send(features);
else
- (session.log or log)("warn", "No stream features to offer");
- session:close({
- condition = "undefined-condition";
- text = "No stream features to proceed with on "..(session.secure and "" or "in").."secure stream";
- });
+ if session.secure then
+ -- Here SASL should be offered
+ (session.log or log)("warn", "No stream features to offer on secure session. Check authentication settings.");
+ else
+ -- Normally STARTTLS would be offered
+ (session.log or log)("warn", "No stream features to offer on insecure session. Check encryption and security settings.");
+ end
+ session:close{ condition = "undefined-condition", text = "No stream features to proceed with" };
end
end
-function stream_callbacks.streamclosed(session)
+function stream_callbacks.streamclosed(session, attr)
+ -- run _streamclosed in async context
+ session.thread:run({ stream = "closed", attr = attr });
+end
+
+function stream_callbacks._streamclosed(session)
session.log("debug", "Received </stream:stream>");
session:close(false);
end
@@ -125,7 +158,7 @@ function stream_callbacks.error(session, error, data)
session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}")));
session:close("invalid-namespace");
elseif error == "parse-error" then
- (session.log or log)("debug", "Client XML parse error: %s", tostring(data));
+ (session.log or log)("debug", "Client XML parse error: %s", data);
session:close("not-well-formed");
elseif error == "stream-error" then
local condition, text = "undefined-condition";
@@ -153,6 +186,9 @@ end
--- Session methods
local function session_close(session, reason)
local log = session.log or log;
+ local close_event_payload = { session = session, reason = reason };
+ module:context(session.host):fire_event("pre-session-close", close_event_payload);
+ reason = close_event_payload.reason;
if session.conn then
if session.notopen then
session:open_stream();
@@ -161,6 +197,8 @@ local function session_close(session, reason)
local stream_error = st.stanza("stream:error");
if type(reason) == "string" then -- assume stream error
stream_error:tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' });
+ elseif st.is_stanza(reason) then
+ stream_error = reason;
elseif type(reason) == "table" then
if reason.condition then
stream_error:tag(reason.condition, stream_xmlns_attr):up();
@@ -170,8 +208,6 @@ local function session_close(session, reason)
if reason.extra then
stream_error:add_child(reason.extra);
end
- elseif reason.name then -- a stanza
- stream_error = reason;
end
end
stream_error = tostring(stream_error);
@@ -206,27 +242,25 @@ local function session_close(session, reason)
end
end
-module:hook_global("user-deleted", function(event)
- local username, host = event.username, event.host;
- local user = hosts[host].sessions[username];
- if user and user.sessions then
- for _, session in pairs(user.sessions) do
- session:close{ condition = "not-authorized", text = "Account deleted" };
- end
- end
-end, 200);
-
-module:hook_global("user-password-changed", function(event)
- local username, host, resource = event.username, event.host, event.resource;
- local user = hosts[host].sessions[username];
- if user and user.sessions then
- for r, session in pairs(user.sessions) do
- if r ~= resource then
- session:close{ condition = "reset", text = "Password changed" };
+-- Close all user sessions with the specified reason. If leave_resource is
+-- true, the resource named by event.resource will not be closed.
+local function disconnect_user_sessions(reason, leave_resource)
+ return function (event)
+ local username, host, resource = event.username, event.host, event.resource;
+ local user = hosts[host].sessions[username];
+ if user and user.sessions then
+ for r, session in pairs(user.sessions) do
+ if not leave_resource or r ~= resource then
+ session:close(reason);
+ end
end
end
end
-end, 200);
+end
+
+module:hook_global("user-password-changed", disconnect_user_sessions({ condition = "reset", text = "Password changed" }, true), 200);
+module:hook_global("user-roles-changed", disconnect_user_sessions({ condition = "reset", text = "Roles changed" }), 200);
+module:hook_global("user-deleted", disconnect_user_sessions({ condition = "not-authorized", text = "Account deleted" }), 200);
function runner_callbacks:ready()
if self.data.conn then
@@ -254,17 +288,20 @@ function listener.onconnect(conn)
session.log("info", "Client connected");
- -- Client is using legacy SSL (otherwise mod_tls sets this flag)
+ -- Client is using Direct TLS or legacy SSL (otherwise mod_tls sets this flag)
if conn:ssl() then
session.secure = true;
session.encrypted = true;
-- Check if TLS compression is used
local sock = conn:socket();
- if sock.info then
- session.compressed = sock:info"compression";
- elseif sock.compression then
- session.compressed = sock:compression(); --COMPAT mw/luasec-hg
+ local info = sock.info and sock:info();
+ if type(info) == "table" then
+ (session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
+ session.compressed = info.compression;
+ m_tls_params:with_labels(info.protocol, info.cipher):add(1)
+ else
+ (session.log or log)("info", "Stream encrypted");
end
end
@@ -284,7 +321,13 @@ function listener.onconnect(conn)
end
session.thread = runner(function (stanza)
- core_process_stanza(session, stanza);
+ if st.is_stanza(stanza) then
+ core_process_stanza(session, stanza);
+ elseif stanza.stream == "opened" then
+ stream_callbacks._streamopened(session, stanza.attr);
+ elseif stanza.stream == "closed" then
+ stream_callbacks._streamclosed(session, stanza.attr);
+ end
end, runner_callbacks, session);
local filter = session.filter;
@@ -295,8 +338,16 @@ function listener.onconnect(conn)
if data then
local ok, err = stream:feed(data);
if not ok then
- log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_"));
- session:close("not-well-formed");
+ log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
+ if err == "stanza-too-large" then
+ session:close({
+ condition = "policy-violation",
+ text = "XML stanza is too big",
+ extra = st.stanza("stanza-too-big", { xmlns = 'urn:xmpp:errors' }),
+ });
+ else
+ session:close("not-well-formed");
+ end
end
end
end
@@ -305,6 +356,7 @@ function listener.onconnect(conn)
if c2s_timeout then
add_task(c2s_timeout, function ()
if session.type == "c2s_unauthed" then
+ (session.log or log)("debug", "Connection still not authenticated after c2s_timeout=%gs, closing it", c2s_timeout);
session:close("connection-timeout");
end
end);
@@ -339,6 +391,20 @@ function listener.onreadtimeout(conn)
end
end
+function listener.ondrain(conn)
+ local session = sessions[conn];
+ if session then
+ return (hosts[session.host] or prosody).events.fire_event("c2s-ondrain", { session = session });
+ end
+end
+
+function listener.onpredrain(conn)
+ local session = sessions[conn];
+ if session then
+ return (hosts[session.host] or prosody).events.fire_event("c2s-pre-ondrain", { session = session });
+ end
+end
+
local function keepalive(event)
local session = event.session;
if not session.notopen then
@@ -371,10 +437,21 @@ module:provides("net", {
default_port = 5222;
encryption = "starttls";
multiplex = {
+ protocol = "xmpp-client";
+ pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:client%1.*>";
+ };
+});
+
+module:provides("net", {
+ name = "c2s_direct_tls";
+ listener = listener;
+ encryption = "ssl";
+ multiplex = {
pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:client%1.*>";
};
});
+-- COMPAT
module:provides("net", {
name = "legacy_ssl";
listener = listener;
diff --git a/plugins/mod_carbons.lua b/plugins/mod_carbons.lua
index 79d3e737..d8d6b3e3 100644
--- a/plugins/mod_carbons.lua
+++ b/plugins/mod_carbons.lua
@@ -5,10 +5,17 @@
local st = require "util.stanza";
local jid_bare = require "util.jid".bare;
+local jid_resource = require "util.jid".resource;
local xmlns_carbons = "urn:xmpp:carbons:2";
local xmlns_forward = "urn:xmpp:forward:0";
local full_sessions, bare_sessions = prosody.full_sessions, prosody.bare_sessions;
+module:add_feature("urn:xmpp:carbons:rules:0");
+
+local function is_bare(jid)
+ return not jid_resource(jid);
+end
+
local function toggle_carbons(event)
local origin, stanza = event.origin, event.stanza;
local state = stanza.tags[1].name;
@@ -20,6 +27,48 @@ end
module:hook("iq-set/self/"..xmlns_carbons..":disable", toggle_carbons);
module:hook("iq-set/self/"..xmlns_carbons..":enable", toggle_carbons);
+local function should_copy(stanza, c2s, user_bare) --> boolean, reason: string
+ local st_type = stanza.attr.type or "normal";
+ if stanza:get_child("private", xmlns_carbons) then
+ return false, "private";
+ end
+
+ if stanza:get_child("no-copy", "urn:xmpp:hints") then
+ return false, "hint";
+ end
+
+ if not c2s and stanza.attr.to ~= user_bare and stanza:get_child("x", "http://jabber.org/protocol/muc#user") then
+ -- MUC PMs are normally sent to full JIDs
+ return false, "muc-pm";
+ end
+
+ if st_type == "chat" then
+ return true, "type";
+ end
+
+ if st_type == "normal" and stanza:get_child("body") then
+ return true, "type";
+ end
+
+ -- Normal outgoing chat messages are sent to=bare JID. This clause should
+ -- match the error bounces from those, which would have from=bare JID and
+ -- be incoming (not c2s).
+ if st_type == "error" and not c2s and is_bare(stanza.attr.from) then
+ return true, "bounce";
+ end
+
+ if stanza:get_child(nil, "urn:xmpp:jingle-message:0") then
+ -- XXX Experimental XEP
+ return true, "jingle call";
+ end
+
+ if stanza:get_child_with_attr("stanza-id", "urn:xmpp:sid:0", "by", user_bare) then
+ return true, "archived";
+ end
+
+ return false, "default";
+end
+
local function message_handler(event, c2s)
local origin, stanza = event.origin, event.stanza;
local orig_type = stanza.attr.type or "normal";
@@ -28,10 +77,6 @@ local function message_handler(event, c2s)
local orig_to = stanza.attr.to;
local bare_to = jid_bare(orig_to);
- if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body"))) then
- return -- Only chat type messages
- end
-
-- Stanza sent by a local client
local bare_jid = bare_from; -- JID of the local user
local target_session = origin;
@@ -56,35 +101,21 @@ local function message_handler(event, c2s)
return -- No use in sending carbons to an offline user
end
- if stanza:get_child("private", xmlns_carbons) then
- if not c2s then
+ local should, why = should_copy(stanza, c2s, bare_jid);
+
+ if not should then
+ module:log("debug", "Not copying stanza: %s (%s)", stanza:top_tag(), why);
+ if why == "private" and not c2s then
stanza:maptags(function(tag)
if not ( tag.attr.xmlns == xmlns_carbons and tag.name == "private" ) then
return tag;
end
end);
end
- module:log("debug", "Message tagged private, ignoring");
- return
- elseif stanza:get_child("no-copy", "urn:xmpp:hints") then
- module:log("debug", "Message has no-copy hint, ignoring");
- return
- elseif not c2s and bare_jid ~= orig_to and stanza:get_child("x", "http://jabber.org/protocol/muc#user") then
- module:log("debug", "MUC PM, ignoring");
- return
- end
-
- -- Create the carbon copy and wrap it as per the Stanza Forwarding XEP
- local copy = st.clone(stanza);
- if c2s and not orig_to then
- stanza.attr.to = bare_from;
+ return;
end
- copy.attr.xmlns = "jabber:client";
- local carbon = st.message{ from = bare_jid, type = orig_type, }
- :tag(c2s and "sent" or "received", { xmlns = xmlns_carbons })
- :tag("forwarded", { xmlns = xmlns_forward })
- :add_child(copy):reset();
+ local carbon;
user_sessions = user_sessions and user_sessions.sessions;
for _, session in pairs(user_sessions) do
-- Carbons are sent to resources that have enabled it
@@ -93,6 +124,20 @@ local function message_handler(event, c2s)
and session ~= target_session
-- and isn't among the top resources that would receive the message per standard routing rules
and (c2s or session.priority ~= top_priority) then
+ if not carbon then
+ -- Create the carbon copy and wrap it as per the Stanza Forwarding XEP
+ local copy = st.clone(stanza);
+ if c2s and not orig_to then
+ stanza.attr.to = bare_from;
+ end
+ copy.attr.xmlns = "jabber:client";
+ carbon = st.message{ from = bare_jid, type = orig_type, }
+ :tag(c2s and "sent" or "received", { xmlns = xmlns_carbons })
+ :tag("forwarded", { xmlns = xmlns_forward })
+ :add_child(copy):reset();
+
+ end
+
carbon.attr.to = session.full_jid;
module:log("debug", "Sending carbon to %s", session.full_jid);
session.send(carbon);
diff --git a/plugins/mod_component.lua b/plugins/mod_component.lua
index 51d731c7..f57c4381 100644
--- a/plugins/mod_component.lua
+++ b/plugins/mod_component.lua
@@ -50,6 +50,7 @@ function module.add_host(module)
local send;
local function on_destroy(session, err) --luacheck: ignore 212/err
+ module:set_status("warn", err and ("Disconnected: "..err) or "Disconnected");
env.connected = false;
env.session = false;
send = nil;
@@ -103,6 +104,7 @@ function module.add_host(module)
module:log("info", "External component successfully authenticated");
session.send(st.stanza("handshake"));
module:fire_event("component-authenticated", { session = session });
+ module:set_status("info", "Connected");
return true;
end
@@ -131,7 +133,8 @@ function module.add_host(module)
end
module:log("warn", "Component not connected, bouncing error for: %s", stanza:top_tag());
if stanza.attr.type ~= "error" and stanza.attr.type ~= "result" then
- event.origin.send(st.error_reply(stanza, "wait", "service-unavailable", "Component unavailable"));
+ event.origin.send(st.error_reply(stanza, "wait", "remote-server-timeout", "Component unavailable", module.host)
+ :tag("not-connected", { xmlns = "xmpp:prosody.im/protocol/component" }));
end
end
return true;
@@ -166,11 +169,11 @@ local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
function stream_callbacks.error(session, error, data)
if session.destroyed then return; end
- module:log("warn", "Error processing component stream: %s", tostring(error));
+ module:log("warn", "Error processing component stream: %s", error);
if error == "no-stream" then
session:close("invalid-namespace");
elseif error == "parse-error" then
- session.log("warn", "External component %s XML parse error: %s", tostring(session.host), tostring(data));
+ session.log("warn", "External component %s XML parse error: %s", session.host, data);
session:close("not-well-formed");
elseif error == "stream-error" then
local condition, text = "undefined-condition";
@@ -191,6 +194,10 @@ function stream_callbacks.error(session, error, data)
end
function stream_callbacks.streamopened(session, attr)
+ if not attr.to then
+ session:close{ condition = "improper-addressing", text = "A 'to' attribute is required on stream headers" };
+ return;
+ end
if not hosts[attr.to] or not hosts[attr.to].modules.component then
session:close{ condition = "host-unknown", text = tostring(attr.to).." does not match any configured external components" };
return;
@@ -207,7 +214,7 @@ function stream_callbacks.streamclosed(session)
session:close();
end
-local function handleerr(err) log("error", "Traceback[component]: %s", traceback(tostring(err), 2)); end
+local function handleerr(err) log("error", "Traceback[component]: %s", traceback(err, 2)); end
function stream_callbacks.handlestanza(session, stanza)
-- Namespaces are icky.
if not stanza.attr.xmlns and stanza.name == "handshake" then
@@ -258,6 +265,9 @@ local function session_close(session, reason)
if type(reason) == "string" then -- assume stream error
module:log("info", "Disconnecting component, <stream:error> is: %s", reason);
session.send(st.stanza("stream:error"):tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' }));
+ elseif st.is_stanza(reason) then
+ module:log("info", "Disconnecting component, <stream:error> is: %s", reason);
+ session.send(reason);
elseif type(reason) == "table" then
if reason.condition then
local stanza = st.stanza("stream:error"):tag(reason.condition, stream_xmlns_attr):up();
@@ -267,11 +277,8 @@ local function session_close(session, reason)
if reason.extra then
stanza:add_child(reason.extra);
end
- module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(stanza));
+ module:log("info", "Disconnecting component, <stream:error> is: %s", stanza);
session.send(stanza);
- elseif reason.name then -- a stanza
- module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(reason));
- session.send(reason);
end
end
end
@@ -311,7 +318,7 @@ function listener.onconnect(conn)
function session.data(_, data)
local ok, err = stream:feed(data);
if ok then return; end
- module:log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_"));
+ log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
session:close("not-well-formed");
end
@@ -326,7 +333,7 @@ end
function listener.ondisconnect(conn, err)
local session = sessions[conn];
if session then
- (session.log or log)("info", "component disconnected: %s (%s)", tostring(session.host), tostring(err));
+ (session.log or log)("info", "component disconnected: %s (%s)", session.host, err);
if session.host then
module:context(session.host):fire_event("component-disconnected", { session = session, reason = err });
end
diff --git a/plugins/mod_cron.lua b/plugins/mod_cron.lua
new file mode 100644
index 00000000..f0c85615
--- /dev/null
+++ b/plugins/mod_cron.lua
@@ -0,0 +1,64 @@
+module:set_global();
+
+local async = require("util.async");
+local datetime = require("util.datetime");
+
+local periods = { hourly = 3600; daily = 86400; weekly = 7 * 86400 }
+
+local active_hosts = {}
+
+function module.add_host(host_module)
+
+ local last_run_times = host_module:open_store("cron", "map");
+ active_hosts[host_module.host] = true;
+
+ local function save_task(task, started_at) last_run_times:set(nil, task.id, started_at); end
+
+ local function task_added(event)
+ local task = event.item;
+ if task.name == nil then task.name = task.when; end
+ if task.id == nil then task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower(); end
+ if task.last == nil then task.last = last_run_times:get(nil, task.id); end
+ task.save = save_task;
+ module:log("debug", "%s task %s added, last run %s", task.when, task.id,
+ task.last and datetime.datetime(task.last) or "never");
+ if task.last == nil then
+ local now = os.time();
+ task.last = now - now % periods[task.when];
+ end
+ return true
+ end
+
+ local function task_removed(event)
+ local task = event.item;
+ host_module:log("debug", "Task %s removed", task.id);
+ return true
+ end
+
+ host_module:handle_items("task", task_added, task_removed, true);
+
+ function host_module.unload() active_hosts[host_module.host] = nil; end
+end
+
+local function should_run(when, last) return not last or last + periods[when] * 0.995 <= os.time() end
+
+local function run_task(task)
+ local started_at = os.time();
+ task:run(started_at);
+ task:save(started_at);
+end
+
+local task_runner = async.runner(run_task);
+scheduled = module:add_timer(1, function()
+ module:log("info", "Running periodic tasks");
+ local delay = 3600;
+ for host in pairs(active_hosts) do
+ module:log("debug", "Running periodic tasks for host %s", host);
+ for _, task in ipairs(module:context(host):get_host_items("task")) do
+ module:log("debug", "Considering %s task %s (%s)", task.when, task.id, task.run);
+ if should_run(task.when, task.last) then task_runner:run(task); end
+ end
+ end
+ module:log("debug", "Wait %ds", delay);
+ return delay
+end);
diff --git a/plugins/mod_csi.lua b/plugins/mod_csi.lua
index 84476cac..458ff491 100644
--- a/plugins/mod_csi.lua
+++ b/plugins/mod_csi.lua
@@ -2,8 +2,9 @@ local st = require "util.stanza";
local xmlns_csi = "urn:xmpp:csi:0";
local csi_feature = st.stanza("csi", { xmlns = xmlns_csi });
+local csi_handler_available = nil;
module:hook("stream-features", function (event)
- if event.origin.username then
+ if event.origin.username and csi_handler_available then
event.features:add_child(csi_feature);
end
end);
@@ -21,3 +22,14 @@ end
module:hook("stanza/"..xmlns_csi..":active", refire_event("csi-client-active"));
module:hook("stanza/"..xmlns_csi..":inactive", refire_event("csi-client-inactive"));
+function module.load()
+ if prosody.hosts[module.host].events._handlers["csi-client-active"] then
+ csi_handler_available = true;
+ module:set_status("core", "CSI handler module loaded");
+ else
+ csi_handler_available = false;
+ module:set_status("warn", "No CSI handler module loaded");
+ end
+end
+module:hook("module-loaded", module.load);
+module:hook("module-unloaded", module.load);
diff --git a/plugins/mod_csi_simple.lua b/plugins/mod_csi_simple.lua
index 7fb6b41f..2420705a 100644
--- a/plugins/mod_csi_simple.lua
+++ b/plugins/mod_csi_simple.lua
@@ -1,4 +1,4 @@
--- Copyright (C) 2016-2018 Kim Alvefur
+-- Copyright (C) 2016-2020 Kim Alvefur
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
@@ -9,115 +9,267 @@ module:depends"csi"
local jid = require "util.jid";
local st = require "util.stanza";
local dt = require "util.datetime";
-local new_queue = require "util.queue".new;
-
-local function new_pump(output, ...)
- -- luacheck: ignore 212/self
- local q = new_queue(...);
- local flush = true;
- function q:pause()
- flush = false;
- end
- function q:resume()
- flush = true;
- return q:flush();
- end
- local push = q.push;
- function q:push(item)
- local ok = push(self, item);
- if not ok then
- q:flush();
- output(item, self);
- elseif flush then
- return q:flush();
- end
- return true;
- end
- function q:flush()
- local item = self:pop();
- while item do
- output(item, self);
- item = self:pop();
- end
- return true;
- end
- return q;
-end
+local filters = require "util.filters";
+local timer = require "util.timer";
local queue_size = module:get_option_number("csi_queue_size", 256);
+local resume_delay = module:get_option_number("csi_resume_inactive_delay", 5);
-module:hook("csi-is-stanza-important", function (event)
- local stanza = event.stanza;
- if not st.is_stanza(stanza) then
- return true;
+local important_payloads = module:get_option_set("csi_important_payloads", { });
+
+function is_important(stanza) --> boolean, reason: string
+ if stanza == " " then
+ return true, "whitespace keepalive";
+ elseif type(stanza) == "string" then
+ return true, "raw data";
+ elseif not st.is_stanza(stanza) then
+ -- This should probably never happen
+ return true, type(stanza);
+ end
+ if stanza.attr.xmlns ~= nil then
+ -- stream errors, stream management etc
+ return true, "nonza";
end
local st_name = stanza.name;
if not st_name then return false; end
local st_type = stanza.attr.type;
if st_name == "presence" then
- if st_type == nil or st_type == "unavailable" then
- return false;
+ if st_type == nil or st_type == "unavailable" or st_type == "error" then
+ return false, "presence update";
end
- return true;
+ -- TODO Some MUC awareness, e.g. check for the 'this relates to you' status code
+ return true, "subscription request";
elseif st_name == "message" then
if st_type == "headline" then
- return false;
+ -- Headline messages are ephemeral by definition
+ return false, "headline";
+ end
+ if st_type == "error" then
+ return true, "delivery failure";
end
if stanza:get_child("sent", "urn:xmpp:carbons:2") then
- return true;
+ return true, "carbon";
end
local forwarded = stanza:find("{urn:xmpp:carbons:2}received/{urn:xmpp:forward:0}/{jabber:client}message");
if forwarded then
stanza = forwarded;
end
if stanza:get_child("body") then
- return true;
+ return true, "body";
end
if stanza:get_child("subject") then
- return true;
+ -- Last step of a MUC join
+ return true, "subject";
end
if stanza:get_child("encryption", "urn:xmpp:eme:0") then
- return true;
+ -- Since we can't know what an encrypted message contains, we assume it's important
+ -- XXX Experimental XEP
+ return true, "encrypted";
+ end
+ if stanza:get_child("x", "jabber:x:conference") or stanza:find("{http://jabber.org/protocol/muc#user}x/invite") then
+ return true, "invite";
end
if stanza:get_child(nil, "urn:xmpp:jingle-message:0") then
- return true;
+ -- XXX Experimental XEP
+ return true, "jingle call";
+ end
+ for important in important_payloads do
+ if stanza:find(important) then
+ return true;
+ end
end
return false;
+ elseif st_name == "iq" then
+ return true;
end
- return true;
+end
+
+module:hook("csi-is-stanza-important", function (event)
+ local important, why = is_important(event.stanza);
+ event.reason = why;
+ return important;
end, -1);
-module:hook("csi-client-inactive", function (event)
- local session = event.origin;
- if session.pump then
- session.pump:pause();
- else
- local bare_jid = jid.join(session.username, session.host);
- local send = session.send;
- session._orig_send = send;
- local pump = new_pump(session.send, queue_size);
- pump:pause();
- session.pump = pump;
- function session.send(stanza)
- if session.state == "active" or module:fire_event("csi-is-stanza-important", { stanza = stanza, session = session }) then
- pump:flush();
- send(stanza);
- else
- if st.is_stanza(stanza) and stanza.attr.xmlns == nil and stanza.name ~= "iq" then
- stanza = st.clone(stanza);
- stanza:add_direct_child(st.stanza("delay", {xmlns = "urn:xmpp:delay", from = bare_jid, stamp = dt.datetime()}));
- end
- pump:push(stanza);
- end
- return true;
+local function should_flush(stanza, session, ctr) --> boolean, reason: string
+ if ctr >= queue_size then
+ return true, "queue size limit reached";
+ end
+ local event = { stanza = stanza, session = session };
+ local ret = module:fire_event("csi-is-stanza-important", event)
+ return ret, event.reason;
+end
+
+local function with_timestamp(stanza, from)
+ if st.is_stanza(stanza) and stanza.attr.xmlns == nil and stanza.name ~= "iq" then
+ stanza = st.clone(stanza);
+ stanza:add_direct_child(st.stanza("delay", {xmlns = "urn:xmpp:delay", from = from, stamp = dt.datetime()}));
+ end
+ return stanza;
+end
+
+local measure_buffer_hold = module:measure("buffer_hold", "times",
+ { buckets = { 0.1; 1; 5; 10; 15; 30; 60; 120; 180; 300; 600; 900 } });
+
+local flush_reasons = module:metric(
+ "counter", "flushes", "",
+ "CSI queue flushes",
+ { "reason" }
+);
+
+local function manage_buffer(stanza, session)
+ local ctr = session.csi_counter or 0;
+ if session.state ~= "inactive" then
+ session.csi_counter = ctr + 1;
+ return stanza;
+ end
+ local flush, why = should_flush(stanza, session, ctr);
+ if flush then
+ if session.csi_measure_buffer_hold then
+ session.csi_measure_buffer_hold();
+ session.csi_measure_buffer_hold = nil;
end
+ flush_reasons:with_labels(why or "important"):add(1);
+ session.log("debug", "Flushing buffer (%s; queue size is %d)", why or "important", session.csi_counter);
+ session.state = "flushing";
+ module:fire_event("csi-flushing", { session = session });
+ session.conn:resume_writes();
+ else
+ session.log("debug", "Holding buffer (%s; queue size is %d)", why or "unimportant", session.csi_counter);
+ stanza = with_timestamp(stanza, jid.join(session.username, session.host))
end
+ session.csi_counter = ctr + 1;
+ return stanza;
+end
+
+local function flush_buffer(data, session)
+ local ctr = session.csi_counter or 0;
+ if ctr == 0 or session.state ~= "inactive" then return data end
+ session.log("debug", "Flushing buffer (%s; queue size is %d)", "client activity", session.csi_counter);
+ session.state = "flushing";
+ module:fire_event("csi-flushing", { session = session });
+ flush_reasons:with_labels("client activity"):add(1);
+ if session.csi_measure_buffer_hold then
+ session.csi_measure_buffer_hold();
+ session.csi_measure_buffer_hold = nil;
+ end
+ session.conn:resume_writes();
+ return data;
+end
+
+function enable_optimizations(session)
+ if session.conn and session.conn.pause_writes then
+ session.conn:pause_writes();
+ session.csi_measure_buffer_hold = measure_buffer_hold();
+ session.csi_counter = 0;
+ filters.add_filter(session, "stanzas/out", manage_buffer);
+ filters.add_filter(session, "bytes/in", flush_buffer);
+ else
+ session.log("warn", "Session connection does not support write pausing");
+ end
+end
+
+function disable_optimizations(session)
+ filters.remove_filter(session, "stanzas/out", manage_buffer);
+ filters.remove_filter(session, "bytes/in", flush_buffer);
+ session.csi_counter = nil;
+ if session.csi_measure_buffer_hold then
+ session.csi_measure_buffer_hold();
+ session.csi_measure_buffer_hold = nil;
+ end
+ if session.conn and session.conn.resume_writes then
+ session.conn:resume_writes();
+ end
+end
+
+module:hook("csi-client-inactive", function (event)
+ local session = event.origin;
+ enable_optimizations(session);
end);
module:hook("csi-client-active", function (event)
local session = event.origin;
- if session.pump then
- session.pump:resume();
+ disable_optimizations(session);
+end);
+
+module:hook("pre-resource-unbind", function (event)
+ local session = event.session;
+ disable_optimizations(session);
+end, 1);
+
+local function resume_optimizations(_, _, session)
+ if (session.state == "flushing" or session.state == "inactive") and session.conn and session.conn.pause_writes then
+ session.state = "inactive";
+ session.conn:pause_writes();
+ session.csi_measure_buffer_hold = measure_buffer_hold();
+ session.log("debug", "Buffer flushed, resuming inactive mode (queue size was %d)", session.csi_counter);
+ session.csi_counter = 0;
+ end
+ session.csi_resume = nil;
+end
+
+module:hook("c2s-ondrain", function (event)
+ local session = event.session;
+ if (session.state == "flushing" or session.state == "inactive") and session.conn and session.conn.pause_writes then
+ -- After flushing, remain in pseudo-flushing state for a moment to allow
+ -- some followup traffic, iq replies, smacks acks to be sent without having
+ -- to go back and forth between inactive and flush mode.
+ if not session.csi_resume then
+ session.csi_resume = timer.add_task(resume_delay, resume_optimizations, session);
+ end
+ -- Should further noise in this short grace period push back the delay?
+ -- Probably not great if the session can be kept in pseudo-active mode
+ -- indefinitely.
end
end);
+function module.load()
+ for _, user_session in pairs(prosody.hosts[module.host].sessions) do
+ for _, session in pairs(user_session.sessions) do
+ if session.state == "inactive" then
+ enable_optimizations(session);
+ end
+ end
+ end
+end
+
+function module.unload()
+ for _, user_session in pairs(prosody.hosts[module.host].sessions) do
+ for _, session in pairs(user_session.sessions) do
+ if session.state and session.state ~= "active" then
+ disable_optimizations(session);
+ end
+ end
+ end
+end
+
+function module.command(arg)
+ if arg[1] ~= "test" then
+ print("Usage: "..module.name.." test < test-stream.xml")
+ print("");
+ print("Provide a series of stanzas to test against importance algorithm");
+ return 1;
+ end
+ -- luacheck: ignore 212/self
+ local xmppstream = require "util.xmppstream";
+ local input_session = { notopen = true }
+ local stream_callbacks = { stream_ns = "jabber:client", default_ns = "jabber:client" };
+ function stream_callbacks:handlestanza(stanza)
+ local important, because = is_important(stanza);
+ print("--");
+ print(stanza:indent(nil, " "));
+ -- :pretty_print() maybe?
+ if important then
+ print((because or "unspecified reason").. " -> important");
+ else
+ print((because or "unspecified reason").. " -> unimportant");
+ end
+ end
+ local input_stream = xmppstream.new(input_session, stream_callbacks);
+ input_stream:reset();
+ input_stream:feed(st.stanza("stream", { xmlns = "jabber:client" }):top_tag());
+ input_session.notopen = nil;
+
+ for line in io.lines() do
+ input_stream:feed(line);
+ end
+end
diff --git a/plugins/mod_dialback.lua b/plugins/mod_dialback.lua
index 4e9f9458..66082333 100644
--- a/plugins/mod_dialback.lua
+++ b/plugins/mod_dialback.lua
@@ -77,9 +77,14 @@ module:hook("stanza/jabber:server:dialback:result", function(event)
local origin, stanza = event.origin, event.stanza;
if origin.type == "s2sin_unauthed" or origin.type == "s2sin" then
- -- he wants to be identified through dialback
+ -- They want to be identified through dialback
-- We need to check the key with the Authoritative server
local attr = stanza.attr;
+ if not attr.to or not attr.from then
+ origin.log("debug", "Missing Dialback addressing (from=%q, to=%q)", attr.from, attr.to);
+ origin:close("improper-addressing");
+ return true;
+ end
local to, from = nameprep(attr.to), nameprep(attr.from);
if not hosts[to] then
@@ -89,6 +94,7 @@ module:hook("stanza/jabber:server:dialback:result", function(event)
return true;
elseif not from then
origin:close("improper-addressing");
+ return true;
end
@@ -123,7 +129,7 @@ module:hook("stanza/jabber:server:dialback:verify", function(event)
if dialback_verifying and attr.from == origin.to_host then
local valid;
if attr.type == "valid" then
- module:fire_event("s2s-authenticated", { session = dialback_verifying, host = attr.from });
+ module:fire_event("s2s-authenticated", { session = dialback_verifying, host = attr.from, mechanism = "dialback" });
valid = "valid";
else
-- Warn the original connection that is was not verified successfully
@@ -160,7 +166,7 @@ module:hook("stanza/jabber:server:dialback:result", function(event)
return true;
end
if stanza.attr.type == "valid" then
- module:fire_event("s2s-authenticated", { session = origin, host = attr.from });
+ module:fire_event("s2s-authenticated", { session = origin, host = attr.from, mechanism = "dialback" });
else
origin:close("not-authorized", "dialback authentication failed");
end
diff --git a/plugins/mod_disco.lua b/plugins/mod_disco.lua
index a5479f4c..a98fc7ac 100644
--- a/plugins/mod_disco.lua
+++ b/plugins/mod_disco.lua
@@ -8,11 +8,14 @@
local get_children = require "core.hostmanager".get_children;
local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
+local um_is_admin = require "core.usermanager".is_admin;
local jid_split = require "util.jid".split;
local jid_bare = require "util.jid".bare;
local st = require "util.stanza"
local calculate_hash = require "util.caps".calculate_hash;
+local expose_admins = module:get_option_boolean("disco_expose_admins", false);
+
local disco_items = module:get_option_array("disco_items", {})
do -- validate disco_items
for _, item in ipairs(disco_items) do
@@ -71,6 +74,7 @@ local function build_server_disco_info()
ver = _cached_server_caps_hash;
});
end
+
local function clear_disco_cache()
_cached_server_disco_info, _cached_server_caps_feature, _cached_server_caps_hash = nil, nil, nil;
end
@@ -116,6 +120,7 @@ module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function(
origin.send(reply);
return true;
end);
+
module:hook("iq-get/host/http://jabber.org/protocol/disco#items:query", function(event)
local origin, stanza = event.origin, event.stanza;
local node = stanza.tags[1].attr.node;
@@ -151,13 +156,20 @@ module:hook("stream-features", function (event)
end
end);
+module:hook("s2s-stream-features", function (event)
+ if event.origin.type == "s2sin" then
+ event.features:add_child(get_server_caps_feature());
+ end
+end);
+
-- Handle disco requests to user accounts
if module:get_host_type() ~= "local" then return end -- skip for components
module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event)
local origin, stanza = event.origin, event.stanza;
local node = stanza.tags[1].attr.node;
local username = jid_split(stanza.attr.to) or origin.username;
- if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
+ local is_admin = um_is_admin(stanza.attr.to or origin.full_jid, module.host)
+ if not stanza.attr.to or (expose_admins and is_admin) or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
if node and node ~= "" then
local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info', node=node});
if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
@@ -173,12 +185,19 @@ module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(
end
local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info'});
if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
- reply:tag('identity', {category='account', type='registered'}):up();
+ if is_admin then
+ reply:tag('identity', {category='account', type='admin'}):up();
+ elseif prosody.hosts[module.host].users.name == "anonymous" then
+ reply:tag('identity', {category='account', type='anonymous'}):up();
+ else
+ reply:tag('identity', {category='account', type='registered'}):up();
+ end
module:fire_event("account-disco-info", { origin = origin, reply = reply });
origin.send(reply);
return true;
end
end);
+
module:hook("iq-get/bare/http://jabber.org/protocol/disco#items:query", function(event)
local origin, stanza = event.origin, event.stanza;
local node = stanza.tags[1].attr.node;
@@ -204,3 +223,4 @@ module:hook("iq-get/bare/http://jabber.org/protocol/disco#items:query", function
return true;
end
end);
+
diff --git a/plugins/mod_external_services.lua b/plugins/mod_external_services.lua
new file mode 100644
index 00000000..6daa45c7
--- /dev/null
+++ b/plugins/mod_external_services.lua
@@ -0,0 +1,243 @@
+
+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;
+ else
+ module:log("warn", "Service missing recommended 'transport' field: %q", item);
+ end
+ if type(item.host) == "string" then
+ srv.host = item.host;
+ end
+ if type(item.port) == "number" then
+ srv.port = item.port;
+ elseif not srv.port then
+ module:log("warn", "Service missing recommended 'port' field: %q", item);
+ 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 extras = module:get_host_items("external_service");
+ local services = ( configured_services + extras ) / prepare;
+ if #services == 0 then
+ module:set_status("warn", "No services configured or all had errors");
+ end
+end
+
+module:handle_items("external_service", function(added)
+ if prepare(added.item) then
+ module:set_status("core", "OK");
+ end
+end, module.load);
+
+-- 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/plugins/mod_groups.lua b/plugins/mod_groups.lua
index 646b7408..0c44f481 100644
--- a/plugins/mod_groups.lua
+++ b/plugins/mod_groups.lua
@@ -25,7 +25,7 @@ function inject_roster_contacts(event)
local function import_jids_to_roster(group_name)
for jid in pairs(groups[group_name]) do
-- Add them to roster
- --module:log("debug", "processing jid %s in group %s", tostring(jid), tostring(group_name));
+ --module:log("debug", "processing jid %s in group %s", jid, group_name);
if jid ~= bare_jid then
if not roster[jid] then roster[jid] = {}; end
roster[jid].subscription = "both";
@@ -99,7 +99,7 @@ function module.load()
end
members[false][#members[false]+1] = curr_group; -- Is a public group
end
- module:log("debug", "New group: %s", tostring(curr_group));
+ module:log("debug", "New group: %s", curr_group);
groups[curr_group] = groups[curr_group] or {};
else
-- Add JID
@@ -108,7 +108,7 @@ function module.load()
local jid;
jid = jid_prep(entryjid:match("%S+"));
if jid then
- module:log("debug", "New member of %s: %s", tostring(curr_group), tostring(jid));
+ module:log("debug", "New member of %s: %s", curr_group, jid);
groups[curr_group][jid] = name or false;
members[jid] = members[jid] or {};
members[jid][#members[jid]+1] = curr_group;
diff --git a/plugins/mod_http.lua b/plugins/mod_http.lua
index b6ba23ac..ef951450 100644
--- a/plugins/mod_http.lua
+++ b/plugins/mod_http.lua
@@ -7,13 +7,21 @@
--
module:set_global();
-module:depends("http_errors");
+pcall(function ()
+ module:depends("http_errors");
+end);
local portmanager = require "core.portmanager";
local moduleapi = require "core.moduleapi";
local url_parse = require "socket.url".parse;
local url_build = require "socket.url".build;
local normalize_path = require "util.http".normalize_path;
+local set = require "util.set";
+
+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 server = require "net.http.server";
@@ -22,6 +30,12 @@ server.set_default_host(module:get_option_string("http_default_host"));
server.set_option("body_size_limit", module:get_option_number("http_max_content_size"));
server.set_option("buffer_size_limit", module:get_option_number("http_max_buffer_size"));
+-- CORS settings
+local opt_methods = module:get_option_set("access_control_allow_methods", { "GET", "OPTIONS" });
+local opt_headers = module:get_option_set("access_control_allow_headers", { "Content-Type" });
+local opt_credentials = module:get_option_boolean("access_control_allow_credentials", false);
+local opt_max_age = module:get_option_number("access_control_max_age", 2 * 60 * 60);
+
local function get_http_event(host, app_path, key)
local method, path = key:match("^(%S+)%s+(.+)$");
if not method then -- No path specified, default to "" (base path)
@@ -60,29 +74,50 @@ local ports_by_scheme = { http = 80, https = 443, };
-- Helper to deduce a module's external URL
function moduleapi.http_url(module, app_name, default_path)
app_name = app_name or (module.name:gsub("^http_", ""));
- local external_url = url_parse(module:get_option_string("http_external_url")) or {};
- if external_url.scheme and external_url.port == nil then
- external_url.port = ports_by_scheme[external_url.scheme];
+
+ local external_url = url_parse(module:get_option_string("http_external_url"));
+ if external_url then
+ local url = {
+ scheme = external_url.scheme;
+ host = external_url.host;
+ port = tonumber(external_url.port) or ports_by_scheme[external_url.scheme];
+ path = normalize_path(external_url.path or "/", true)
+ .. (get_base_path(module, app_name, default_path or "/" .. app_name):sub(2));
+ }
+ if ports_by_scheme[url.scheme] == url.port then url.port = nil end
+ return url_build(url);
end
+
local services = portmanager.get_active_services();
local http_services = services:get("https") or services:get("http") or {};
for interface, ports in pairs(http_services) do -- luacheck: ignore 213/interface
for port, service in pairs(ports) do -- luacheck: ignore 512
local url = {
- scheme = (external_url.scheme or service[1].service.name);
- host = (external_url.host or module:get_option_string("http_host", module.host));
- port = tonumber(external_url.port) or port or 80;
- path = normalize_path(external_url.path or "/", true)..
- (get_base_path(module, app_name, default_path or "/"..app_name):sub(2));
+ scheme = service[1].service.name;
+ host = module:get_option_string("http_host", module.host);
+ port = port;
+ path = get_base_path(module, app_name, default_path or "/" .. app_name);
}
if ports_by_scheme[url.scheme] == url.port then url.port = nil end
return url_build(url);
end
end
- module:log("warn", "No http ports enabled, can't generate an external URL");
+ if prosody.process_type == "prosody" then
+ module:log("warn", "No http ports enabled, can't generate an external URL");
+ end
return "http://disabled.invalid/";
end
+local function apply_cors_headers(response, methods, headers, max_age, allow_credentials, origin)
+ response.headers.access_control_allow_methods = tostring(methods);
+ response.headers.access_control_allow_headers = tostring(headers);
+ response.headers.access_control_max_age = tostring(max_age)
+ response.headers.access_control_allow_origin = origin or "*";
+ if allow_credentials then
+ response.headers.access_control_allow_credentials = "true";
+ end
+end
+
function module.add_host(module)
local host = module.host;
if host ~= "*" then
@@ -101,9 +136,53 @@ function module.add_host(module)
end
apps[app_name] = apps[app_name] or {};
local app_handlers = apps[app_name];
- for key, handler in pairs(event.item.route or {}) do
+
+ local app_methods = opt_methods;
+ local app_headers = opt_headers;
+ local app_credentials = opt_credentials;
+
+ local function cors_handler(event_data)
+ local request, response = event_data.request, event_data.response;
+ apply_cors_headers(response, app_methods, app_headers, opt_max_age, app_credentials, request.headers.origin);
+ end
+
+ local function options_handler(event_data)
+ cors_handler(event_data);
+ return "";
+ end
+
+ if event.item.cors then
+ local cors = event.item.cors;
+ if cors.credentials ~= nil then
+ app_credentials = cors.credentials;
+ end
+ if cors.headers then
+ for header, enable in pairs(cors.headers) do
+ if enable and not app_headers:contains(header) then
+ app_headers = app_headers + set.new { header };
+ elseif not enable and app_headers:contains(header) then
+ app_headers = app_headers - set.new { header };
+ end
+ end
+ end
+ end
+
+ local streaming = event.item.streaming_uploads;
+
+ if not event.item.route then
+ -- TODO: Link to docs
+ module:log("error", "HTTP app %q provides no 'route', add one to handle HTTP requests", app_name);
+ return;
+ end
+
+ for key, handler in pairs(event.item.route) do
local event_name = get_http_event(host, app_path, key);
if event_name then
+ local method = event_name:match("^%S+");
+ if not app_methods:contains(method) then
+ app_methods = app_methods + set.new{ method };
+ end
+ local options_event_name = event_name:gsub("^%S+", "OPTIONS");
if type(handler) ~= "function" then
local data = handler;
handler = function () return data; end
@@ -118,9 +197,24 @@ function module.add_host(module)
elseif event_name:sub(-1, -1) == "/" then
module:hook_object_event(server, event_name:sub(1, -2), redir_handler, -1);
end
+ if not streaming then
+ -- COMPAT Modules not compatible with streaming uploads behave as before.
+ local _handler = handler;
+ function handler(event) -- luacheck: ignore 432/event
+ if event.request.body ~= false then
+ return _handler(event);
+ end
+ end
+ end
if not app_handlers[event_name] then
- app_handlers[event_name] = handler;
+ app_handlers[event_name] = {
+ main = handler;
+ cors = cors_handler;
+ options = options_handler;
+ };
module:hook_object_event(server, event_name, handler);
+ module:hook_object_event(server, event_name, cors_handler, 1);
+ module:hook_object_event(server, options_event_name, options_handler, -1);
else
module:log("warn", "App %s added handler twice for '%s', ignoring", app_name, event_name);
end
@@ -130,17 +224,27 @@ function module.add_host(module)
end
local services = portmanager.get_active_services();
if services:get("https") or services:get("http") then
- module:log("debug", "Serving '%s' at %s", app_name, module:http_url(app_name, app_path));
- else
- module:log("warn", "Not listening on any ports, '%s' will be unreachable", app_name);
+ module:log("info", "Serving '%s' at %s", app_name, module:http_url(app_name, app_path));
+ elseif prosody.process_type == "prosody" then
+ module:log("error", "Not listening on any ports, '%s' will be unreachable", app_name);
end
end
local function http_app_removed(event)
local app_handlers = apps[event.item.name];
apps[event.item.name] = nil;
- for event_name, handler in pairs(app_handlers) do
- module:unhook_object_event(server, event_name, handler);
+ for event_name, handlers in pairs(app_handlers) do
+ module:unhook_object_event(server, event_name, handlers.main);
+ module:unhook_object_event(server, event_name, handlers.cors);
+
+ if event_name:sub(-2, -1) == "/*" then
+ module:unhook_object_event(server, event_name:sub(1, -3), redir_handler, -1);
+ elseif event_name:sub(-1, -1) == "/" then
+ module:unhook_object_event(server, event_name:sub(1, -2), redir_handler, -1);
+ end
+
+ local options_event_name = event_name:gsub("^%S+", "OPTIONS");
+ module:unhook_object_event(server, options_event_name, handlers.options);
end
end
@@ -158,25 +262,50 @@ module.add_host(module); -- set up handling on global context too
local trusted_proxies = module:get_option_set("trusted_proxies", { "127.0.0.1", "::1" })._items;
-local function get_ip_from_request(request)
- local ip = request.conn:ip();
+local function is_trusted_proxy(ip)
+ if trusted_proxies[ip] then
+ return true;
+ end
+ local parsed_ip = new_ip(ip)
+ for trusted_proxy in trusted_proxies do
+ if match_ip(parsed_ip, parse_cidr(trusted_proxy)) then
+ return true;
+ end
+ end
+ return false
+end
+
+local function get_forwarded_connection_info(request) --> ip:string, secure:boolean
+ local ip = request.ip;
+ local secure = request.secure; -- set by net.http.server
local forwarded_for = request.headers.x_forwarded_for;
- if forwarded_for and trusted_proxies[ip] then
+ if forwarded_for then
+ -- luacheck: ignore 631
+ -- This logic looks weird at first, but it makes sense.
+ -- The for loop will take the last non-trusted-proxy IP from `forwarded_for`.
+ -- We append the original request IP to the header. Then, since the last IP wins, there are two cases:
+ -- Case a) The original request IP is *not* in trusted proxies, in which case the X-Forwarded-For header will, effectively, be ineffective; the original request IP will win because it overrides any other IP in the header.
+ -- Case b) The original request IP is in trusted proxies. In that case, the if branch in the for loop will skip the last IP, causing it to be ignored. The second-to-last IP will be taken instead.
+ -- Case c) If the second-to-last IP is also a trusted proxy, it will also be ignored, iteratively, up to the last IP which isn’t in trusted proxies.
+ -- Case d) If all IPs are in trusted proxies, something went obviously wrong and the logic never overwrites `ip`, leaving it at the original request IP.
forwarded_for = forwarded_for..", "..ip;
for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do
- if not trusted_proxies[forwarded_ip] then
+ if not is_trusted_proxy(forwarded_ip) then
ip = forwarded_ip;
end
end
end
- return ip;
+
+ secure = secure or request.headers.x_forwarded_proto == "https";
+
+ return ip, secure;
end
module:wrap_object_event(server._events, false, function (handlers, event_name, event_data)
local request = event_data.request;
- if request then
+ if request and is_trusted_proxy(request.ip) then
-- Not included in eg http-error events
- request.ip = get_ip_from_request(request);
+ request.ip, request.secure = get_forwarded_connection_info(request);
end
return handlers(event_name, event_data);
end);
@@ -184,6 +313,7 @@ end);
module:provides("net", {
name = "http";
listener = server.listener;
+ private = true;
default_port = 5280;
multiplex = {
pattern = "^[A-Z]";
@@ -195,10 +325,8 @@ module:provides("net", {
listener = server.listener;
default_port = 5281;
encryption = "ssl";
- ssl_config = {
- verify = "none";
- };
multiplex = {
+ protocol = "http/1.1";
pattern = "^[A-Z]";
};
});
diff --git a/plugins/mod_http_errors.lua b/plugins/mod_http_errors.lua
index 13473219..ec54860c 100644
--- a/plugins/mod_http_errors.lua
+++ b/plugins/mod_http_errors.lua
@@ -15,6 +15,15 @@ local default_messages = {
"Where did you put it?", "It's behind you.", "Keep looking." };
[500] = { "% Check your error log for more info.";
"Gremlins.", "It broke.", "Don't look at me." };
+ ["/"] = {
+ "A study in simplicity.";
+ "Better catch it!";
+ "Don't just stand there, go after it!";
+ "Well, say something, before it runs too far!";
+ "Welcome to the world of XMPP!";
+ "You can do anything in XMPP!"; -- "The only limit is XML.";
+ "You can do anything with Prosody!"; -- the only limit is memory?
+ };
};
local messages = setmetatable(module:get_option("http_errors_messages", {}), { __index = default_messages });
@@ -26,28 +35,22 @@ local html = [[
<meta charset="utf-8">
<title>{title}</title>
<style>
-body{
- margin-top:14%;
- text-align:center;
- background-color:#F8F8F8;
- font-family:sans-serif;
-}
-h1{
- font-size:xx-large;
-}
-p{
- font-size:x-large;
-}
-p+p {
- font-size:large;
- font-family:courier;
+body{margin-top:14%;text-align:center;background-color:#f8f8f8;font-family:sans-serif}
+h1{font-size:xx-large}
+p{font-size:x-large}
+p.warning>span{font-size:large;background-color:yellow}
+p.extra{font-size:large;font-family:courier}
+@media(prefers-color-scheme:dark){
+body{background-color:#161616;color:#eee}
+p.warning>span{background-color:inherit;color:yellow}
}
</style>
</head>
<body>
-<h1>{title}</h1>
+<h1>{icon?{icon_raw!?}} {title}</h1>
<p>{message}</p>
-<p>{extra?}</p>
+{warning&<p class="warning"><span>&#9888; {warning?} &#9888;</span></p>}
+{extra&<p class="extra">{extra?}</p>}
</body>
</html>
]];
@@ -66,9 +69,43 @@ local function get_page(code, extra)
end
end
+-- Main error page handler
module:hook_object_event(server, "http-error", function (event)
if event.response then
event.response.headers.content_type = "text/html; charset=utf-8";
end
- return get_page(event.code, (show_private and event.private_message) or event.message);
+ return get_page(event.code, (show_private and event.private_message) or event.message or (event.error and event.error.text));
end);
+
+-- Way to use the template for other things so to give a consistent appearance
+module:hook("http-message", function (event)
+ if event.response then
+ event.response.headers.content_type = "text/html; charset=utf-8";
+ end
+ return render(html, event);
+end, -1);
+
+local icon = [[
+<svg xmlns="http://www.w3.org/2000/svg" height="0.7em" viewBox="0 0 480 480" width="0.7em">
+<rect fill="#6197df" height="220" rx="60" ry="60" width="220" x="10" y="10"></rect>
+<rect fill="#f29b00" height="220" rx="60" ry="60" width="220" x="10" y="240"></rect>
+<rect fill="#f29b00" height="220" rx="60" ry="60" width="220" x="240" y="10"></rect>
+<rect fill="#6197df" height="220" rx="60" ry="60" width="220" x="240" y="240"></rect>
+</svg>
+]];
+
+-- Something nicer shown instead of 404 at the root path, if nothing else handles this path
+module:hook_object_event(server, "http-error", function (event)
+ local request, response = event.request, event.response;
+ if request and response and request.path == "/" and response.status_code == 404 then
+ response.status_code = 200;
+ response.headers.content_type = "text/html; charset=utf-8";
+ local message = messages["/"];
+ return render(html, {
+ icon_raw = icon,
+ title = "Prosody is running!";
+ message = message[math.random(#message)];
+ });
+ end
+end, 1);
+
diff --git a/plugins/mod_http_file_share.lua b/plugins/mod_http_file_share.lua
new file mode 100644
index 00000000..8773f3a4
--- /dev/null
+++ b/plugins/mod_http_file_share.lua
@@ -0,0 +1,599 @@
+-- Prosody IM
+-- Copyright (C) 2021 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- XEP-0363: HTTP File Upload
+-- Again, from the top!
+
+local t_insert = table.insert;
+local jid = require "util.jid";
+local st = require "util.stanza";
+local url = require "socket.url";
+local dm = require "core.storagemanager".olddm;
+local jwt = require "util.jwt";
+local errors = require "util.error";
+local dataform = require "util.dataforms".new;
+local dt = require "util.datetime";
+local hi = require "util.human.units";
+local cache = require "util.cache";
+local lfs = require "lfs";
+
+local unknown = math.abs(0/0);
+local unlimited = math.huge;
+
+local namespace = "urn:xmpp:http:upload:0";
+
+module:depends("disco");
+
+module:add_identity("store", "file", module:get_option_string("name", "HTTP File Upload"));
+module:add_feature(namespace);
+
+local uploads = module:open_store("uploads", "archive");
+local persist_stats = module:open_store("upload_stats", "map");
+-- id, <request>, time, owner
+
+local secret = module:get_option_string(module.name.."_secret", require"util.id".long());
+local external_base_url = module:get_option_string(module.name .. "_base_url");
+local file_size_limit = module:get_option_number(module.name .. "_size_limit", 10 * 1024 * 1024); -- 10 MB
+local file_types = module:get_option_set(module.name .. "_allowed_file_types", {});
+local safe_types = module:get_option_set(module.name .. "_safe_file_types", {"image/*","video/*","audio/*","text/plain"});
+local expiry = module:get_option_number(module.name .. "_expires_after", 7 * 86400);
+local daily_quota = module:get_option_number(module.name .. "_daily_quota", file_size_limit*10); -- 100 MB / day
+local total_storage_limit = module:get_option_number(module.name.."_global_quota", unlimited);
+
+local access = module:get_option_set(module.name .. "_access", {});
+
+if not external_base_url then
+ module:depends("http");
+end
+
+module:add_extension(dataform {
+ { name = "FORM_TYPE", type = "hidden", value = namespace },
+ { name = "max-file-size", type = "text-single", datatype = "xs:integer" },
+}:form({ ["max-file-size"] = file_size_limit }, "result"));
+
+local upload_errors = errors.init(module.name, namespace, {
+ access = { type = "auth"; condition = "forbidden" };
+ filename = { type = "modify"; condition = "bad-request"; text = "Invalid filename" };
+ filetype = { type = "modify"; condition = "not-acceptable"; text = "File type not allowed" };
+ filesize = { type = "modify"; condition = "not-acceptable"; text = "File too large";
+ extra = {tag = st.stanza("file-too-large", {xmlns = namespace}):tag("max-file-size"):text(tostring(file_size_limit)) };
+ };
+ filesizefmt = { type = "modify"; condition = "bad-request"; text = "File size must be positive integer"; };
+ quota = { type = "wait"; condition = "resource-constraint"; text = "Daily quota reached"; };
+ outofdisk = { type = "wait"; condition = "resource-constraint"; text = "Server global storage quota reached" };
+});
+
+local upload_cache = cache.new(1024);
+local quota_cache = cache.new(1024);
+
+local total_storage_usage = unknown;
+
+local measure_upload_cache_size = module:measure("upload_cache", "amount");
+local measure_quota_cache_size = module:measure("quota_cache", "amount");
+local measure_total_storage_usage = module:measure("total_storage", "amount", { unit = "bytes" });
+
+do
+ local total, err = persist_stats:get(nil, "total");
+ if not err then
+ total_storage_usage = tonumber(total) or 0;
+ end
+end
+
+module:hook_global("stats-update", function ()
+ measure_upload_cache_size(upload_cache:count());
+ measure_quota_cache_size(quota_cache:count());
+ measure_total_storage_usage(total_storage_usage);
+end);
+
+local buckets = {};
+for n = 10, 40, 2 do
+ local exp = math.floor(2 ^ n);
+ table.insert(buckets, exp);
+ if exp >= file_size_limit then break end
+end
+local measure_uploads = module:measure("upload", "sizes", {buckets = buckets});
+
+-- Convenience wrapper for logging file sizes
+local function B(bytes)
+ if bytes ~= bytes then
+ return "unknown"
+ elseif bytes == unlimited then
+ return "unlimited";
+ end
+ return hi.format(bytes, "B", "b");
+end
+
+local function get_filename(slot, create)
+ return dm.getpath(slot, module.host, module.name, "bin", create)
+end
+
+function get_daily_quota(uploader)
+ local now = os.time();
+ local max_age = now - 86400;
+ local cached = quota_cache:get(uploader);
+ if cached and cached.time > max_age then
+ return cached.size;
+ end
+ local iter, err = uploads:find(nil, {with = uploader; start = max_age });
+ if not iter then return iter, err; end
+ local total_bytes = 0;
+ local oldest_upload = now;
+ for _, slot, when in iter do
+ local size = tonumber(slot.attr.size);
+ if size then total_bytes = total_bytes + size; end
+ if when < oldest_upload then oldest_upload = when; end
+ end
+ -- If there were no uploads then we end up caching [now, 0], which is fine
+ -- since we increase the size on new uploads
+ quota_cache:set(uploader, { time = oldest_upload, size = total_bytes });
+ return total_bytes;
+end
+
+function may_upload(uploader, filename, filesize, filetype) -- > boolean, error
+ local uploader_host = jid.host(uploader);
+ if not ((access:empty() and prosody.hosts[uploader_host]) or access:contains(uploader) or access:contains(uploader_host)) then
+ return false, upload_errors.new("access");
+ end
+
+ if not filename or filename:find"/" then
+ -- On Linux, only '/' and '\0' are invalid in filenames and NUL can't be in XML
+ return false, upload_errors.new("filename");
+ end
+
+ if not filesize or filesize < 0 or filesize % 1 ~= 0 then
+ return false, upload_errors.new("filesizefmt");
+ end
+ if filesize > file_size_limit then
+ return false, upload_errors.new("filesize");
+ end
+
+ if total_storage_usage + filesize > total_storage_limit then
+ module:log("warn", "Global storage quota reached, at %s / %s!", B(total_storage_usage), B(total_storage_limit));
+ return false, upload_errors.new("outofdisk");
+ end
+
+ local uploader_quota = get_daily_quota(uploader);
+ if uploader_quota + filesize > daily_quota then
+ return false, upload_errors.new("quota");
+ end
+
+ if not ( file_types:empty() or file_types:contains(filetype) or file_types:contains(filetype:gsub("/.*", "/*")) ) then
+ return false, upload_errors.new("filetype");
+ end
+
+ return true;
+end
+
+function get_authz(slot, uploader, filename, filesize, filetype)
+local now = os.time();
+ return jwt.sign(secret, {
+ -- token properties
+ sub = uploader;
+ iat = now;
+ exp = now+300;
+
+ -- slot properties
+ slot = slot;
+ expires = expiry >= 0 and (now+expiry) or nil;
+ -- file properties
+ filename = filename;
+ filesize = filesize;
+ filetype = filetype;
+ });
+end
+
+function get_url(slot, filename)
+ local base_url = external_base_url or module:http_url();
+ local slot_url = url.parse(base_url);
+ slot_url.path = url.parse_path(slot_url.path or "/");
+ t_insert(slot_url.path, slot);
+ if filename then
+ t_insert(slot_url.path, filename);
+ slot_url.path.is_directory = false;
+ else
+ slot_url.path.is_directory = true;
+ end
+ slot_url.path = url.build_path(slot_url.path);
+ return url.build(slot_url);
+end
+
+function handle_slot_request(event)
+ local stanza, origin = event.stanza, event.origin;
+
+ local request = st.clone(stanza.tags[1], true);
+ local filename = request.attr.filename;
+ local filesize = tonumber(request.attr.size);
+ local filetype = request.attr["content-type"] or "application/octet-stream";
+ local uploader = jid.bare(stanza.attr.from);
+
+ local may, why_not = may_upload(uploader, filename, filesize, filetype);
+ if not may then
+ origin.send(st.error_reply(stanza, why_not));
+ return true;
+ end
+
+ module:log("info", "Issuing upload slot to %s for %s", uploader, B(filesize));
+ local slot, storage_err = errors.coerce(uploads:append(nil, nil, request, os.time(), uploader))
+ if not slot then
+ origin.send(st.error_reply(stanza, storage_err));
+ return true;
+ end
+
+ total_storage_usage = total_storage_usage + filesize;
+ module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+
+ local cached_quota = quota_cache:get(uploader);
+ if cached_quota and cached_quota.time > os.time()-86400 then
+ cached_quota.size = cached_quota.size + filesize;
+ quota_cache:set(uploader, cached_quota);
+ end
+
+ local authz = get_authz(slot, uploader, filename, filesize, filetype);
+ local slot_url = get_url(slot, filename);
+ local upload_url = slot_url;
+
+ local reply = st.reply(stanza)
+ :tag("slot", { xmlns = namespace })
+ :tag("get", { url = slot_url }):up()
+ :tag("put", { url = upload_url })
+ :text_tag("header", "Bearer "..authz, {name="Authorization"})
+ :reset();
+
+ origin.send(reply);
+ return true;
+end
+
+function handle_upload(event, path) -- PUT /upload/:slot
+ local request = event.request;
+ local authz = request.headers.authorization;
+ if authz then
+ authz = authz:match("^Bearer (.*)")
+ end
+ if not authz then
+ module:log("debug", "Missing or malformed Authorization header");
+ event.response.headers.www_authenticate = "Bearer";
+ return 401;
+ end
+ local authed, upload_info = jwt.verify(secret, authz);
+ if not (authed and type(upload_info) == "table" and type(upload_info.exp) == "number") then
+ module:log("debug", "Unauthorized or invalid token: %s, %q", authed, upload_info);
+ return 401;
+ end
+ if not request.body_sink and upload_info.exp < os.time() then
+ module:log("debug", "Authorization token expired on %s", dt.datetime(upload_info.exp));
+ return 410;
+ end
+ if not path or upload_info.slot ~= path:match("^[^/]+") then
+ module:log("debug", "Invalid upload slot: %q, path: %q", upload_info.slot, path);
+ return 400;
+ end
+ if request.headers.content_length and tonumber(request.headers.content_length) ~= upload_info.filesize then
+ return 413;
+ -- Note: We don't know the size if the upload is streamed in chunked encoding,
+ -- so we also check the final file size on completion.
+ end
+
+ local filename = get_filename(upload_info.slot, true);
+
+ do
+ -- check if upload has been completed already
+ -- we want to allow retry of a failed upload attempt, but not after it's been completed
+ local f = io.open(filename, "r");
+ if f then
+ f:close();
+ return 409;
+ end
+ end
+
+ if not request.body_sink then
+ module:log("debug", "Preparing to receive upload into %q, expecting %s", filename, B(upload_info.filesize));
+ local fh, err = io.open(filename.."~", "w");
+ if not fh then
+ module:log("error", "Could not open file for writing: %s", err);
+ return 500;
+ end
+ function event.response:on_destroy() -- luacheck: ignore 212/self
+ -- Clean up incomplete upload
+ if io.type(fh) == "file" then -- still open
+ fh:close();
+ os.remove(filename.."~");
+ end
+ end
+ request.body_sink = fh;
+ if request.body == false then
+ if request.headers.expect == "100-continue" then
+ request.conn:write("HTTP/1.1 100 Continue\r\n\r\n");
+ end
+ return true;
+ end
+ end
+
+ if request.body then
+ module:log("debug", "Complete upload available, %s", B(#request.body));
+ -- Small enough to have been uploaded already
+ local written, err = errors.coerce(request.body_sink:write(request.body));
+ if not written then
+ return err;
+ end
+ request.body = nil;
+ end
+
+ if request.body_sink then
+ local final_size = request.body_sink:seek();
+ local uploaded, err = errors.coerce(request.body_sink:close());
+ if final_size ~= upload_info.filesize then
+ -- Could be too short as well, but we say the same thing
+ uploaded, err = false, 413;
+ end
+ if uploaded then
+ module:log("debug", "Upload of %q completed, %s", filename, B(final_size));
+ assert(os.rename(filename.."~", filename));
+ measure_uploads(final_size);
+
+ upload_cache:set(upload_info.slot, {
+ name = upload_info.filename;
+ size = tostring(upload_info.filesize);
+ type = upload_info.filetype;
+ time = os.time();
+ });
+ return 201;
+ else
+ assert(os.remove(filename.."~"));
+ return err;
+ end
+ end
+
+end
+
+local download_cache_hit = module:measure("download_cache_hit", "rate");
+local download_cache_miss = module:measure("download_cache_miss", "rate");
+
+function handle_download(event, path) -- GET /uploads/:slot+filename
+ local request, response = event.request, event.response;
+ local slot_id = path:match("^[^/]+");
+ local basename, filetime, filetype, filesize;
+ local cached = upload_cache:get(slot_id);
+ if cached then
+ module:log("debug", "Cache hit");
+ download_cache_hit();
+ basename = cached.name;
+ filesize = cached.size;
+ filetype = cached.type;
+ filetime = cached.time;
+ upload_cache:set(slot_id, cached);
+ -- TODO cache negative hits?
+ else
+ module:log("debug", "Cache miss");
+ download_cache_miss();
+ local slot, when = errors.coerce(uploads:get(nil, slot_id));
+ if not slot then
+ module:log("debug", "uploads:get(%q) --> not-found, %s", slot_id, when);
+ else
+ module:log("debug", "uploads:get(%q) --> %s, %d", slot_id, slot, when);
+ basename = slot.attr.filename;
+ filesize = slot.attr.size;
+ filetype = slot.attr["content-type"];
+ filetime = when;
+ upload_cache:set(slot_id, {
+ name = basename;
+ size = slot.attr.size;
+ type = filetype;
+ time = when;
+ });
+ end
+ end
+ if not basename then
+ return 404;
+ end
+ local last_modified = os.date('!%a, %d %b %Y %H:%M:%S GMT', filetime);
+ if request.headers.if_modified_since == last_modified then
+ return 304;
+ end
+ local filename = get_filename(slot_id);
+ local handle, ferr = io.open(filename);
+ if not handle then
+ module:log("error", "Could not open file for reading: %s", ferr);
+ -- This can be because the upload slot wasn't used, or the file disappeared
+ -- somehow, or permission issues.
+ return 410;
+ end
+
+ local request_range = request.headers.range;
+ local response_range;
+ if request_range then
+ local range_start, range_end = request_range:match("^bytes=(%d+)%-(%d*)$")
+ -- Only support resumption, ie ranges from somewhere in the middle until the end of the file.
+ if (range_start and range_start ~= "0") and (range_end == "" or range_end == filesize) then
+ local pos, size = tonumber(range_start), tonumber(filesize);
+ local new_pos = pos < size and handle:seek("set", pos);
+ if new_pos and new_pos < size then
+ response_range = "bytes "..range_start.."-"..filesize.."/"..filesize;
+ filesize = string.format("%d", size-pos);
+ else
+ handle:close();
+ return 416;
+ end
+ end
+ end
+
+
+ if not filetype then
+ filetype = "application/octet-stream";
+ end
+ local disposition = "attachment";
+ if safe_types:contains(filetype) or safe_types:contains(filetype:gsub("/.*", "/*")) then
+ disposition = "inline";
+ end
+
+ response.headers.last_modified = last_modified;
+ response.headers.content_length = filesize;
+ response.headers.content_type = filetype;
+ response.headers.content_disposition = string.format("%s; filename=%q", disposition, basename);
+
+ if response_range then
+ response.status_code = 206;
+ response.headers.content_range = response_range;
+ end
+ response.headers.accept_ranges = "bytes";
+
+ response.headers.cache_control = "max-age=31556952, immutable";
+ response.headers.content_security_policy = "default-src 'none'; frame-ancestors 'none';"
+ response.headers.strict_transport_security = "max-age=31556952";
+ response.headers.x_content_type_options = "nosniff";
+ response.headers.x_frame_options = "DENY"; -- COMPAT IE missing support for CSP frame-ancestors
+ response.headers.x_xss_protection = "1; mode=block";
+
+ return response:send_file(handle);
+end
+
+if expiry >= 0 and not external_base_url then
+ -- TODO HTTP DELETE to the external endpoint?
+ local array = require "util.array";
+ local async = require "util.async";
+ local ENOENT = require "util.pposix".ENOENT;
+
+ local function sleep(t)
+ local wait, done = async.waiter();
+ module:add_timer(t, done)
+ wait();
+ end
+
+ local prune_start = module:measure("prune", "times");
+
+ module:daily("Remove expired files", function(_, current_time)
+ local prune_done = prune_start();
+ local boundary_time = (current_time or os.time()) - expiry;
+ local iter, total = assert(uploads:find(nil, {["end"] = boundary_time; total = true}));
+
+ if total == 0 then
+ module:log("info", "No expired uploaded files to prune");
+ prune_done();
+ return;
+ end
+
+ module:log("info", "Pruning expired files uploaded earlier than %s", dt.datetime(boundary_time));
+ module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+
+ local obsolete_uploads = array();
+ local num_expired = 0;
+ local size_sum = 0;
+ local problem_deleting = false;
+ for slot_id, slot_info in iter do
+ num_expired = num_expired + 1;
+ upload_cache:set(slot_id, nil);
+ local filename = get_filename(slot_id);
+ local deleted, err, errno = os.remove(filename);
+ if deleted or errno == ENOENT then -- removed successfully or it was already gone
+ size_sum = size_sum + tonumber(slot_info.attr.size);
+ obsolete_uploads:push(slot_id);
+ else
+ module:log("error", "Could not prune expired file %q: %s", filename, err);
+ problem_deleting = true;
+ end
+ if num_expired % 100 == 0 then sleep(0.1); end
+ end
+
+ -- obsolete_uploads now contains slot ids for which the files have been
+ -- removed and that needs to be cleared from the database
+
+ local deletion_query = {["end"] = boundary_time};
+ if not problem_deleting then
+ module:log("info", "All (%d, %s) expired files successfully pruned", num_expired, B(size_sum));
+ -- we can delete based on time
+ else
+ module:log("warn", "%d out of %d expired files could not be pruned", num_expired-#obsolete_uploads, num_expired);
+ -- we'll need to delete only those entries where the files were
+ -- successfully removed, and then try again with the failed ones.
+ -- eventually the admin ought to notice and fix the permissions or
+ -- whatever the problem is.
+ deletion_query = {ids = obsolete_uploads};
+ end
+
+ total_storage_usage = total_storage_usage - size_sum;
+ module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+ persist_stats:set(nil, "total", total_storage_usage);
+
+ if #obsolete_uploads == 0 then
+ module:log("debug", "No metadata to remove");
+ else
+ local removed, err = uploads:delete(nil, deletion_query);
+
+ if removed == true or removed == num_expired or removed == #obsolete_uploads then
+ module:log("debug", "Expired upload metadata pruned successfully");
+ else
+ module:log("error", "Problem removing metadata for expired files: %s", err);
+ end
+ end
+
+ prune_done();
+ end);
+end
+
+local summary_start = module:measure("summary", "times");
+
+module:weekly("Calculate total storage usage", function()
+ local summary_done = summary_start();
+ local iter = assert(uploads:find(nil));
+
+ local count, sum = 0, 0;
+ for _, file in iter do
+ sum = sum + tonumber(file.attr.size);
+ count = count + 1;
+ end
+
+ module:log("info", "Uploaded files total: %s in %d files", B(sum), count);
+ if persist_stats:set(nil, "total", sum) then
+ total_storage_usage = sum;
+ else
+ total_storage_usage = unknown;
+ end
+ module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+ summary_done();
+end);
+
+-- Reachable from the console
+function check_files(query)
+ local issues = {};
+ local iter = assert(uploads:find(nil, query));
+ for slot_id, file in iter do
+ local filename = get_filename(slot_id);
+ local size, err = lfs.attributes(filename, "size");
+ if not size then
+ issues[filename] = err;
+ elseif tonumber(file.attr.size) ~= size then
+ issues[filename] = "file size mismatch";
+ end
+ end
+
+ return next(issues) == nil, issues;
+end
+
+module:hook("iq-get/host/urn:xmpp:http:upload:0:request", handle_slot_request);
+
+if not external_base_url then
+module:provides("http", {
+ streaming_uploads = true;
+ cors = {
+ credentials = true;
+ headers = {
+ Authorization = true;
+ };
+ };
+ route = {
+ ["PUT /*"] = handle_upload;
+ ["GET /*"] = handle_download;
+ ["GET /"] = function (event)
+ return prosody.events.fire_event("http-message", {
+ response = event.response;
+ ---
+ title = "Prosody HTTP Upload endpoint";
+ message = "This is where files will be uploaded to, and served from.";
+ warning = not (event.request.secure) and "This endpoint is not considered secure!" or nil;
+ }) or "This is the Prosody HTTP Upload endpoint.";
+ end
+ }
+ });
+end
diff --git a/plugins/mod_http_files.lua b/plugins/mod_http_files.lua
index a8398c01..4d0b14cd 100644
--- a/plugins/mod_http_files.lua
+++ b/plugins/mod_http_files.lua
@@ -7,14 +7,9 @@
--
module:depends("http");
-local server = require"net.http.server";
-local lfs = require "lfs";
-local os_date = os.date;
local open = io.open;
-local stat = lfs.attributes;
-local build_path = require"socket.url".build_path;
-local path_sep = package.config:sub(1,1);
+local fileserver = require"net.http.files";
local base_path = module:get_option_path("http_files_dir", module:get_option_path("http_path"));
local cache_size = module:get_option_number("http_files_cache_size", 128);
@@ -38,7 +33,9 @@ if not mime_map then
module:shared("/*/http_files/mime").types = mime_map;
local mime_types, err = open(module:get_option_path("mime_types_file", "/etc/mime.types", "config"), "r");
- if mime_types then
+ if not mime_types then
+ module:log("debug", "Could not open MIME database: %s", err);
+ else
local mime_data = mime_types:read("*a");
mime_types:close();
setmetatable(mime_map, {
@@ -51,148 +48,56 @@ if not mime_map then
end
end
-local forbidden_chars_pattern = "[/%z]";
-if prosody.platform == "windows" then
- forbidden_chars_pattern = "[/%z\001-\031\127\"*:<>?|]"
+local function get_calling_module()
+ local info = debug.getinfo(3, "S");
+ if not info then return "An unknown module"; end
+ return info.source:match"mod_[^/\\.]+" or info.short_src;
end
-local urldecode = require "util.http".urldecode;
-function sanitize_path(path)
- if not path then return end
- local out = {};
-
- local c = 0;
- for component in path:gmatch("([^/]+)") do
- component = urldecode(component);
- if component:find(forbidden_chars_pattern) then
- return nil;
- elseif component == ".." then
- if c <= 0 then
- return nil;
- end
- out[c] = nil;
- c = c - 1;
- elseif component ~= "." then
- c = c + 1;
- out[c] = component;
- end
- end
- if path:sub(-1,-1) == "/" then
- out[c+1] = "";
- end
- return "/"..table.concat(out, "/");
-end
-
-local cache = require "util.cache".new(cache_size);
-
+-- COMPAT -- TODO deprecate
function serve(opts)
if type(opts) ~= "table" then -- assume path string
opts = { path = opts };
end
- -- luacheck: ignore 431
- local base_path = opts.path;
- local dir_indices = opts.index_files or dir_indices;
- local directory_index = opts.directory_index;
- local function serve_file(event, path)
- local request, response = event.request, event.response;
- local sanitized_path = sanitize_path(path);
- if path and not sanitized_path then
- return 400;
- end
- path = sanitized_path;
- local orig_path = sanitize_path(request.path);
- local full_path = base_path .. (path or ""):gsub("/", path_sep);
- local attr = stat(full_path:match("^.*[^\\/]")); -- Strip trailing path separator because Windows
- if not attr then
- return 404;
- end
-
- local request_headers, response_headers = request.headers, response.headers;
-
- local last_modified = os_date('!%a, %d %b %Y %H:%M:%S GMT', attr.modification);
- response_headers.last_modified = last_modified;
-
- local etag = ('"%x-%x-%x"'):format(attr.change or 0, attr.size or 0, attr.modification or 0);
- response_headers.etag = etag;
-
- local if_none_match = request_headers.if_none_match
- local if_modified_since = request_headers.if_modified_since;
- if etag == if_none_match
- or (not if_none_match and last_modified == if_modified_since) then
- return 304;
- end
-
- local data = cache:get(orig_path);
- if data and data.etag == etag then
- response_headers.content_type = data.content_type;
- data = data.data;
- elseif attr.mode == "directory" and path then
- if full_path:sub(-1) ~= "/" then
- local dir_path = { is_absolute = true, is_directory = true };
- for dir in orig_path:gmatch("[^/]+") do dir_path[#dir_path+1]=dir; end
- response_headers.location = build_path(dir_path);
- return 301;
- end
- for i=1,#dir_indices do
- if stat(full_path..dir_indices[i], "mode") == "file" then
- return serve_file(event, path..dir_indices[i]);
- end
- end
-
- if directory_index then
- data = server._events.fire_event("directory-index", { path = request.path, full_path = full_path });
- end
- if not data then
- return 403;
- end
- cache:set(orig_path, { data = data, content_type = mime_map.html; etag = etag; });
- response_headers.content_type = mime_map.html;
-
- else
- local f, err = open(full_path, "rb");
- if not f then
- module:log("debug", "Could not open %s. Error was %s", full_path, err);
- return 403;
- end
- local ext = full_path:match("%.([^./]+)$");
- local content_type = ext and mime_map[ext];
- response_headers.content_type = content_type;
- if attr.size > cache_max_file_size then
- response_headers.content_length = attr.size;
- module:log("debug", "%d > cache_max_file_size", attr.size);
- return response:send_file(f);
- else
- data = f:read("*a");
- f:close();
- end
- cache:set(orig_path, { data = data; content_type = content_type; etag = etag });
- end
-
- return response:send(data);
+ if opts.directory_index == nil then
+ opts.directory_index = directory_index;
end
-
- return serve_file;
+ if opts.mime_map == nil then
+ opts.mime_map = mime_map;
+ end
+ if opts.cache_size == nil then
+ opts.cache_size = cache_size;
+ end
+ if opts.cache_max_file_size == nil then
+ opts.cache_max_file_size = cache_max_file_size;
+ end
+ if opts.index_files == nil then
+ opts.index_files = dir_indices;
+ end
+ -- TODO Crank up to warning
+ module:log("debug", "%s should be updated to use 'net.http.files' insead of mod_http_files", get_calling_module());
+ return fileserver.serve(opts);
end
function wrap_route(routes)
+ module:log("debug", "%s should be updated to use 'net.http.files' insead of mod_http_files", get_calling_module());
for route,handler in pairs(routes) do
if type(handler) ~= "function" then
- routes[route] = serve(handler);
+ routes[route] = fileserver.serve(handler);
end
end
return routes;
end
-if base_path then
- module:provides("http", {
- route = {
- ["GET /*"] = serve {
- path = base_path;
- directory_index = directory_index;
- }
- };
- });
-else
- module:log("debug", "http_files_dir not set, assuming use by some other module");
-end
-
+module:provides("http", {
+ route = {
+ ["GET /*"] = fileserver.serve({
+ path = base_path;
+ directory_index = directory_index;
+ mime_map = mime_map;
+ cache_size = cache_size;
+ cache_max_file_size = cache_max_file_size;
+ index_files = dir_indices;
+ });
+ };
+});
diff --git a/plugins/mod_http_openmetrics.lua b/plugins/mod_http_openmetrics.lua
new file mode 100644
index 00000000..0c204ff4
--- /dev/null
+++ b/plugins/mod_http_openmetrics.lua
@@ -0,0 +1,60 @@
+-- Export statistics in OpenMetrics format
+--
+-- Copyright (C) 2014 Daurnimator
+-- Copyright (C) 2018 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
+-- Copyright (C) 2021 Jonas Schäfer <jonas@zombofant.net>
+--
+-- This module is MIT/X11 licensed.
+
+module:set_global();
+
+local statsman = require "core.statsmanager";
+local ip = require "util.ip";
+
+local get_metric_registry = statsman.get_metric_registry;
+local collect = statsman.collect;
+
+local get_metrics;
+
+local permitted_ips = module:get_option_set("openmetrics_allow_ips", { "::1", "127.0.0.1" });
+local permitted_cidr = module:get_option_string("openmetrics_allow_cidr");
+
+local function is_permitted(request)
+ local ip_raw = request.ip;
+ if permitted_ips:contains(ip_raw) or
+ (permitted_cidr and ip.match(ip.new_ip(ip_raw), ip.parse_cidr(permitted_cidr))) then
+ return true;
+ end
+ return false;
+end
+
+function get_metrics(event)
+ if not is_permitted(event.request) then
+ return 403; -- Forbidden
+ end
+
+ local response = event.response;
+ response.headers.content_type = "application/openmetrics-text; version=0.0.4";
+
+ if collect then
+ -- Ensure to get up-to-date samples when running in manual mode
+ collect()
+ end
+
+ local registry = get_metric_registry()
+ if registry == nil then
+ response.headers.content_type = "text/plain; charset=utf-8"
+ response.status_code = 404
+ return "No statistics provider configured\n"
+ end
+
+ return registry:render();
+end
+
+module:depends "http";
+module:provides("http", {
+ default_path = "metrics";
+ route = {
+ GET = get_metrics;
+ };
+});
diff --git a/plugins/mod_invites.lua b/plugins/mod_invites.lua
new file mode 100644
index 00000000..1f284537
--- /dev/null
+++ b/plugins/mod_invites.lua
@@ -0,0 +1,340 @@
+local id = require "util.id";
+local it = require "util.iterators";
+local url = require "socket.url";
+local jid_node = require "util.jid".node;
+local jid_split = require "util.jid".split;
+
+local default_ttl = module:get_option_number("invite_expiry", 86400 * 7);
+
+local token_storage;
+if prosody.process_type == "prosody" or prosody.shutdown then
+ token_storage = module:open_store("invite_token", "map");
+end
+
+local function get_uri(action, jid, token, params) --> string
+ return url.build({
+ scheme = "xmpp",
+ path = jid,
+ query = action..";preauth="..token..(params and (";"..params) or ""),
+ });
+end
+
+local function create_invite(invite_action, invite_jid, allow_registration, additional_data, ttl, reusable)
+ local token = id.medium();
+
+ local created_at = os.time();
+ local expires = created_at + (ttl or default_ttl);
+
+ local invite_params = (invite_action == "roster" and allow_registration) and "ibr=y" or nil;
+
+ local invite = {
+ type = invite_action;
+ jid = invite_jid;
+
+ token = token;
+ allow_registration = allow_registration;
+ additional_data = additional_data;
+
+ uri = get_uri(invite_action, invite_jid, token, invite_params);
+
+ created_at = created_at;
+ expires = expires;
+
+ reusable = reusable;
+ };
+
+ module:fire_event("invite-created", invite);
+
+ if allow_registration then
+ local ok, err = token_storage:set(nil, token, invite);
+ if not ok then
+ module:log("warn", "Failed to store account invite: %s", err);
+ return nil, "internal-server-error";
+ end
+ end
+
+ if invite_action == "roster" then
+ local username = jid_node(invite_jid);
+ local ok, err = token_storage:set(username, token, expires);
+ if not ok then
+ module:log("warn", "Failed to store subscription invite: %s", err);
+ return nil, "internal-server-error";
+ end
+ end
+
+ return invite;
+end
+
+-- Create invitation to register an account (optionally restricted to the specified username)
+function create_account(account_username, additional_data, ttl) --luacheck: ignore 131/create_account
+ local jid = account_username and (account_username.."@"..module.host) or module.host;
+ return create_invite("register", jid, true, additional_data, ttl);
+end
+
+-- Create invitation to reset the password for an account
+function create_account_reset(account_username, ttl) --luacheck: ignore 131/create_account_reset
+ return create_account(account_username, { allow_reset = account_username }, ttl or 86400);
+end
+
+-- Create invitation to become a contact of a local user
+function create_contact(username, allow_registration, additional_data, ttl) --luacheck: ignore 131/create_contact
+ return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl);
+end
+
+-- Create invitation to register an account and join a user group
+-- If explicit ttl is passed, invite is valid for multiple signups
+-- during that time period
+function create_group(group_ids, additional_data, ttl) --luacheck: ignore 131/create_group
+ local merged_additional_data = {
+ groups = group_ids;
+ };
+ if additional_data then
+ for k, v in pairs(additional_data) do
+ merged_additional_data[k] = v;
+ end
+ end
+ return create_invite("register", module.host, true, merged_additional_data, ttl, not not ttl);
+end
+
+-- Iterates pending (non-expired, unused) invites that allow registration
+function pending_account_invites() --luacheck: ignore 131/pending_account_invites
+ local store = module:open_store("invite_token");
+ local now = os.time();
+ local function is_valid_invite(_, invite)
+ return invite.expires > now;
+ end
+ return it.filter(is_valid_invite, pairs(store:get(nil) or {}));
+end
+
+function get_account_invite_info(token) --luacheck: ignore 131/get_account_invite_info
+ if not token then
+ return nil, "no-token";
+ end
+
+ -- Fetch from host store (account invite)
+ local token_info = token_storage:get(nil, token);
+ if not token_info then
+ return nil, "token-invalid";
+ elseif os.time() > token_info.expires then
+ return nil, "token-expired";
+ end
+
+ return token_info;
+end
+
+function delete_account_invite(token) --luacheck: ignore 131/delete_account_invite
+ if not token then
+ return nil, "no-token";
+ end
+
+ return token_storage:set(nil, token, nil);
+end
+
+local valid_invite_methods = {};
+local valid_invite_mt = { __index = valid_invite_methods };
+
+function valid_invite_methods:use()
+ if self.reusable then
+ return true;
+ end
+
+ if self.username then
+ -- Also remove the contact invite if present, on the
+ -- assumption that they now have a mutual subscription
+ token_storage:set(self.username, self.token, nil);
+ end
+ token_storage:set(nil, self.token, nil);
+
+ return true;
+end
+
+-- Get a validated invite (or nil, err). Must call :use() on the
+-- returned invite after it is actually successfully used
+-- For "roster" invites, the username of the local user (who issued
+-- the invite) must be passed.
+-- If no username is passed, but the registration is a roster invite
+-- from a local user, the "inviter" field of the returned invite will
+-- be set to their username.
+function get(token, username)
+ if not token then
+ return nil, "no-token";
+ end
+
+ local valid_until, inviter;
+
+ -- Fetch from host store (account invite)
+ local token_info = token_storage:get(nil, token);
+
+ if username then -- token being used for subscription
+ -- Fetch from user store (subscription invite)
+ valid_until = token_storage:get(username, token);
+ else -- token being used for account creation
+ valid_until = token_info and token_info.expires;
+ if token_info and token_info.type == "roster" then
+ username = jid_node(token_info.jid);
+ inviter = username;
+ end
+ end
+
+ if not valid_until then
+ module:log("debug", "Got unknown token: %s", token);
+ return nil, "token-invalid";
+ elseif os.time() > valid_until then
+ module:log("debug", "Got expired token: %s", token);
+ return nil, "token-expired";
+ end
+
+ return setmetatable({
+ token = token;
+ username = username;
+ inviter = inviter;
+ type = token_info and token_info.type or "roster";
+ uri = token_info and token_info.uri or get_uri("roster", username.."@"..module.host, token);
+ additional_data = token_info and token_info.additional_data or nil;
+ reusable = token_info.reusable;
+ }, valid_invite_mt);
+end
+
+function use(token) --luacheck: ignore 131/use
+ local invite = get(token);
+ return invite and invite:use();
+end
+
+--- shell command
+do
+ -- Since the console is global this overwrites the command for
+ -- each host it's loaded on, but this should be fine.
+
+ local get_module = require "core.modulemanager".get_module;
+
+ local console_env = module:shared("/*/admin_shell/env");
+
+ -- luacheck: ignore 212/self
+ console_env.invite = {};
+ function console_env.invite:create_account(user_jid)
+ local username, host = jid_split(user_jid);
+ local mod_invites, err = get_module(host, "invites");
+ if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
+ local invite, err = mod_invites.create_account(username);
+ if not invite then return nil, err; end
+ return true, invite.uri;
+ end
+
+ function console_env.invite:create_contact(user_jid, allow_registration)
+ local username, host = jid_split(user_jid);
+ local mod_invites, err = get_module(host, "invites");
+ if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
+ local invite, err = mod_invites.create_contact(username, allow_registration);
+ if not invite then return nil, err; end
+ return true, invite.uri;
+ end
+end
+
+--- prosodyctl command
+function module.command(arg)
+ if #arg < 2 or arg[1] ~= "generate" then
+ print("usage: prosodyctl mod_"..module.name.." generate example.com");
+ return 2;
+ end
+ table.remove(arg, 1); -- pop command
+
+ local sm = require "core.storagemanager";
+ local mm = require "core.modulemanager";
+
+ local host = arg[1];
+ assert(prosody.hosts[host], "Host "..tostring(host).." does not exist");
+ sm.initialize_host(host);
+ table.remove(arg, 1); -- pop host
+ module.host = host; --luacheck: ignore 122/module
+ token_storage = module:open_store("invite_token", "map");
+
+ -- Load mod_invites
+ local invites = module:depends("invites");
+ -- Optional community module that if used, needs to be loaded here
+ local invites_page_module = module:get_option_string("invites_page_module", "invites_page");
+ if mm.get_modules_for_host(host):contains(invites_page_module) then
+ module:depends(invites_page_module);
+ end
+
+ local allow_reset;
+ local roles;
+ local groups = {};
+
+ while #arg > 0 do
+ local value = arg[1];
+ table.remove(arg, 1);
+ if value == "--help" then
+ print("usage: prosodyctl mod_"..module.name.." generate DOMAIN --reset USERNAME")
+ print("usage: prosodyctl mod_"..module.name.." generate DOMAIN [--admin] [--role ROLE] [--group GROUPID]...")
+ print()
+ print("This command has two modes: password reset and new account.")
+ print("If --reset is given, the command operates in password reset mode and in new account mode otherwise.")
+ print()
+ print("required arguments in password reset mode:")
+ print()
+ print(" --reset USERNAME Generate a password reset link for the given USERNAME.")
+ print()
+ print("optional arguments in new account mode:")
+ print()
+ print(" --admin Make the new user privileged")
+ print(" Equivalent to --role prosody:admin")
+ print(" --role ROLE Grant the given ROLE to the new user")
+ print(" --group GROUPID Add the user to the group with the given ID")
+ print(" Can be specified multiple times")
+ print()
+ print("--role and --admin override each other; the last one wins")
+ print("--group can be specified multiple times; the user will be added to all groups.")
+ print()
+ print("--reset and the other options cannot be mixed.")
+ return 2
+ elseif value == "--reset" then
+ local nodeprep = require "util.encodings".stringprep.nodeprep;
+ local username = nodeprep(arg[1])
+ table.remove(arg, 1);
+ if not username then
+ print("Please supply a valid username to generate a reset link for");
+ return 2;
+ end
+ allow_reset = username;
+ elseif value == "--admin" then
+ roles = { ["prosody:admin"] = true };
+ elseif value == "--role" then
+ local rolename = arg[1];
+ if not rolename then
+ print("Please supply a role name");
+ return 2;
+ end
+ roles = { [rolename] = true };
+ table.remove(arg, 1);
+ elseif value == "--group" or value == "-g" then
+ local groupid = arg[1];
+ if not groupid then
+ print("Please supply a group ID")
+ return 2;
+ end
+ table.insert(groups, groupid);
+ table.remove(arg, 1);
+ else
+ print("unexpected argument: "..value)
+ end
+ end
+
+ local invite;
+ if allow_reset then
+ if roles then
+ print("--role/--admin and --reset are mutually exclusive")
+ return 2;
+ end
+ if #groups > 0 then
+ print("--group and --reset are mutually exclusive")
+ end
+ invite = assert(invites.create_account_reset(allow_reset));
+ else
+ invite = assert(invites.create_account(nil, {
+ roles = roles,
+ groups = groups
+ }));
+ end
+
+ print(invite.landing_page or invite.uri);
+end
diff --git a/plugins/mod_invites_adhoc.lua b/plugins/mod_invites_adhoc.lua
new file mode 100644
index 00000000..4554d919
--- /dev/null
+++ b/plugins/mod_invites_adhoc.lua
@@ -0,0 +1,126 @@
+-- XEP-0401: Easy User Onboarding
+local dataforms = require "util.dataforms";
+local datetime = require "util.datetime";
+local split_jid = require "util.jid".split;
+local usermanager = require "core.usermanager";
+
+local new_adhoc = module:require("adhoc").new;
+
+-- Whether local users can invite other users to create an account on this server
+local allow_user_invites = module:get_option_boolean("allow_user_invites", false);
+-- Who can see and use the contact invite command. It is strongly recommended to
+-- keep this available to all local users. To allow/disallow invite-registration
+-- on the server, use the option above instead.
+local allow_contact_invites = module:get_option_boolean("allow_contact_invites", true);
+
+local allow_user_invite_roles = module:get_option_set("allow_user_invites_by_roles");
+local deny_user_invite_roles = module:get_option_set("deny_user_invites_by_roles");
+
+local invites;
+if prosody.shutdown then -- COMPAT hack to detect prosodyctl
+ invites = module:depends("invites");
+end
+
+local invite_result_form = dataforms.new({
+ title = "Your invite has been created",
+ {
+ name = "url" ;
+ var = "landing-url";
+ label = "Invite web page";
+ desc = "Share this link";
+ },
+ {
+ name = "uri";
+ label = "Invite URI";
+ desc = "This alternative link can be opened with some XMPP clients";
+ },
+ {
+ name = "expire";
+ label = "Invite valid until";
+ },
+ });
+
+-- This is for checking if the specified JID may create invites
+-- that allow people to register accounts on this host.
+local function may_invite_new_users(jid)
+ if usermanager.get_roles then
+ local user_roles = usermanager.get_roles(jid, module.host);
+ if not user_roles then return; end
+ if user_roles["prosody:admin"] then
+ return true;
+ end
+ if allow_user_invite_roles then
+ for allowed_role in allow_user_invite_roles do
+ if user_roles[allowed_role] then
+ return true;
+ end
+ end
+ end
+ if deny_user_invite_roles then
+ for denied_role in deny_user_invite_roles do
+ if user_roles[denied_role] then
+ return false;
+ end
+ end
+ end
+ elseif usermanager.is_admin(jid, module.host) then -- COMPAT w/0.11
+ return true; -- Admins may always create invitations
+ end
+ -- No role matches, so whatever the default is
+ return allow_user_invites;
+end
+
+module:depends("adhoc");
+
+-- This command is available to all local users, even if allow_user_invites = false
+-- If allow_user_invites is false, creating an invite still works, but the invite will
+-- not be valid for registration on the current server, only for establishing a roster
+-- subscription.
+module:provides("adhoc", new_adhoc("Create new contact invite", "urn:xmpp:invite#invite",
+ function (_, data)
+ local username, host = split_jid(data.from);
+ if host ~= module.host then
+ return {
+ status = "completed";
+ error = {
+ message = "This command is only available to users of "..module.host;
+ };
+ };
+ end
+ local invite = invites.create_contact(username, may_invite_new_users(data.from), {
+ source = data.from
+ });
+ --TODO: check errors
+ return {
+ status = "completed";
+ form = {
+ layout = invite_result_form;
+ values = {
+ uri = invite.uri;
+ url = invite.landing_page;
+ expire = datetime.datetime(invite.expires);
+ };
+ };
+ };
+ end, allow_contact_invites and "local_user" or "admin"));
+
+-- This is an admin-only command that creates a new invitation suitable for registering
+-- a new account. It does not add the new user to the admin's roster.
+module:provides("adhoc", new_adhoc("Create new account invite", "urn:xmpp:invite#create-account",
+ function (_, data)
+ local invite = invites.create_account(nil, {
+ source = data.from
+ });
+ --TODO: check errors
+ return {
+ status = "completed";
+ form = {
+ layout = invite_result_form;
+ values = {
+ uri = invite.uri;
+ url = invite.landing_page;
+ expire = datetime.datetime(invite.expires);
+ };
+ };
+ };
+ end, "admin"));
diff --git a/plugins/mod_invites_register.lua b/plugins/mod_invites_register.lua
new file mode 100644
index 00000000..07c7aa78
--- /dev/null
+++ b/plugins/mod_invites_register.lua
@@ -0,0 +1,160 @@
+local st = require "util.stanza";
+local jid_split = require "util.jid".split;
+local jid_bare = require "util.jid".bare;
+local rostermanager = require "core.rostermanager";
+
+local require_encryption = module:get_option_boolean("c2s_require_encryption",
+ module:get_option_boolean("require_encryption", false));
+local invite_only = module:get_option_boolean("registration_invite_only", true);
+
+local invites;
+if prosody.shutdown then -- COMPAT hack to detect prosodyctl
+ invites = module:depends("invites");
+end
+
+local legacy_invite_stream_feature = st.stanza("register", { xmlns = "urn:xmpp:invite" }):up();
+local invite_stream_feature = st.stanza("register", { xmlns = "urn:xmpp:ibr-token:0" }):up();
+module:hook("stream-features", function(event)
+ local session, features = event.origin, event.features;
+
+ -- Advertise to unauthorized clients only.
+ if session.type ~= "c2s_unauthed" or (require_encryption and not session.secure) then
+ return
+ end
+
+ features:add_child(legacy_invite_stream_feature);
+ features:add_child(invite_stream_feature);
+end);
+
+-- XEP-0379: Pre-Authenticated Roster Subscription
+module:hook("presence/bare", function (event)
+ local stanza = event.stanza;
+ if stanza.attr.type ~= "subscribe" then return end
+
+ local preauth = stanza:get_child("preauth", "urn:xmpp:pars:0");
+ if not preauth then return end
+ local token = preauth.attr.token;
+ if not token then return end
+
+ local username, host = jid_split(stanza.attr.to);
+
+ local invite, err = invites.get(token, username);
+
+ if not invite then
+ module:log("debug", "Got invalid token, error: %s", err);
+ return;
+ end
+
+ local contact = jid_bare(stanza.attr.from);
+
+ module:log("debug", "Approving inbound subscription to %s from %s", username, contact);
+ if rostermanager.set_contact_pending_in(username, host, contact, stanza) then
+ if rostermanager.subscribed(username, host, contact) then
+ invite:use();
+ rostermanager.roster_push(username, host, contact);
+
+ -- Send back a subscription request (goal is mutual subscription)
+ if not rostermanager.is_user_subscribed(username, host, contact)
+ and not rostermanager.is_contact_pending_out(username, host, contact) then
+ module:log("debug", "Sending automatic subscription request to %s from %s", contact, username);
+ if rostermanager.set_contact_pending_out(username, host, contact) then
+ rostermanager.roster_push(username, host, contact);
+ module:send(st.presence({type = "subscribe", from = username.."@"..host, to = contact }));
+ else
+ module:log("warn", "Failed to set contact pending out for %s", username);
+ end
+ end
+ end
+ end
+end, 1);
+
+-- Client is submitting a preauth token to allow registration
+module:hook("stanza/iq/urn:xmpp:pars:0:preauth", function(event)
+ local preauth = event.stanza.tags[1];
+ local token = preauth.attr.token;
+ local validated_invite = invites.get(token);
+ if not validated_invite then
+ local reply = st.error_reply(event.stanza, "cancel", "forbidden", "The invite token is invalid or expired");
+ event.origin.send(reply);
+ return true;
+ end
+ event.origin.validated_invite = validated_invite;
+ local reply = st.reply(event.stanza);
+ event.origin.send(reply);
+ return true;
+end);
+
+-- Registration attempt - ensure a valid preauth token has been supplied
+module:hook("user-registering", function (event)
+ local validated_invite = event.validated_invite or (event.session and event.session.validated_invite);
+ if invite_only and not validated_invite then
+ event.allowed = false;
+ event.reason = "Registration on this server is through invitation only";
+ return;
+ elseif not validated_invite then
+ -- This registration is not using an invite, but
+ -- the server is not in invite-only mode, so nothing
+ -- for this module to do...
+ return;
+ end
+ if validated_invite and validated_invite.additional_data and validated_invite.additional_data.allow_reset then
+ event.allow_reset = validated_invite.additional_data.allow_reset;
+ end
+end);
+
+-- Make a *one-way* subscription. User will see when contact is online,
+-- contact will not see when user is online.
+function subscribe(host, user_username, contact_username)
+ local user_jid = user_username.."@"..host;
+ local contact_jid = contact_username.."@"..host;
+ -- Update user's roster to say subscription request is pending...
+ rostermanager.set_contact_pending_out(user_username, host, contact_jid);
+ -- Update contact's roster to say subscription request is pending...
+ rostermanager.set_contact_pending_in(contact_username, host, user_jid);
+ -- Update contact's roster to say subscription request approved...
+ rostermanager.subscribed(contact_username, host, user_jid);
+ -- Update user's roster to say subscription request approved...
+ rostermanager.process_inbound_subscription_approval(user_username, host, contact_jid);
+end
+
+-- Make a mutual subscription between jid1 and jid2. Each JID will see
+-- when the other one is online.
+function subscribe_both(host, user1, user2)
+ subscribe(host, user1, user2);
+ subscribe(host, user2, user1);
+end
+
+-- Registration successful, if there was a preauth token, mark it as used
+module:hook("user-registered", function (event)
+ local validated_invite = event.validated_invite or (event.session and event.session.validated_invite);
+ if not validated_invite then
+ return;
+ end
+ local inviter_username = validated_invite.inviter;
+ local contact_username = event.username;
+ validated_invite:use();
+
+ if inviter_username then
+ module:log("debug", "Creating mutual subscription between %s and %s", inviter_username, contact_username);
+ subscribe_both(module.host, inviter_username, contact_username);
+ end
+
+ if validated_invite.additional_data then
+ module:log("debug", "Importing roles from invite");
+ local roles = validated_invite.additional_data.roles;
+ if roles then
+ module:open_store("roles"):set(contact_username, roles);
+ end
+ end
+end);
+
+-- Equivalent of user-registered but for when the account already existed
+-- (i.e. password reset)
+module:hook("user-password-reset", function (event)
+ local validated_invite = event.validated_invite or (event.session and event.session.validated_invite);
+ if not validated_invite then
+ return;
+ end
+ validated_invite:use();
+end);
+
diff --git a/plugins/mod_lastactivity.lua b/plugins/mod_lastactivity.lua
index 575e66be..91d11bd2 100644
--- a/plugins/mod_lastactivity.lua
+++ b/plugins/mod_lastactivity.lua
@@ -30,7 +30,7 @@ module:hook("iq-get/bare/jabber:iq:last:query", function(event)
if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
local seconds, text = "0", "";
if map[username] then
- seconds = tostring(os.difftime(os.time(), map[username].t));
+ seconds = string.format("%d", os.difftime(os.time(), map[username].t));
text = map[username].s;
end
origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=seconds}):text(text));
diff --git a/plugins/mod_legacyauth.lua b/plugins/mod_legacyauth.lua
index 0f41d3e7..941806d3 100644
--- a/plugins/mod_legacyauth.lua
+++ b/plugins/mod_legacyauth.lua
@@ -78,8 +78,10 @@ module:hook("stanza/iq/jabber:iq:auth:query", function(event)
session:close(); -- FIXME undo resource bind and auth instead of closing the session?
return true;
end
+ session.send(st.reply(stanza));
+ else
+ session.send(st.error_reply(stanza, "auth", "not-authorized", err));
end
- session.send(st.reply(stanza));
else
session.send(st.error_reply(stanza, "auth", "not-authorized"));
end
diff --git a/plugins/mod_limits.lua b/plugins/mod_limits.lua
index 98b52a96..4f1b618e 100644
--- a/plugins/mod_limits.lua
+++ b/plugins/mod_limits.lua
@@ -32,7 +32,7 @@ local function parse_burst(burst, sess_type)
end
local n_burst = tonumber(burst);
if burst and not n_burst then
- module:log("error", "Unable to parse burst for %s: %q, using default burst interval (%ds)", sess_type, tostring(burst), default_burst);
+ module:log("error", "Unable to parse burst for %s: %q, using default burst interval (%ds)", sess_type, burst, default_burst);
end
return n_burst or default_burst;
end
@@ -60,18 +60,18 @@ end
local default_filter_set = {};
function default_filter_set.bytes_in(bytes, session)
- local sess_throttle = session.throttle;
- if sess_throttle then
- local ok, balance, outstanding = sess_throttle:poll(#bytes, true);
+ local sess_throttle = session.throttle;
+ if sess_throttle then
+ 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);
+ 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
+ if sess_throttle:peek(#outstanding_data) then
session.log("debug", "Resuming paused session");
session.conn:resume();
end
@@ -93,8 +93,13 @@ local function filter_hook(session)
local session_type = session.type:match("^[^_]+");
local filter_set, opts = type_filters[session_type], limits[session_type];
if opts then
- session.throttle = throttle.create(opts.bytes_per_second * opts.burst_seconds, opts.burst_seconds);
- filters.add_filter(session, "bytes/in", filter_set.bytes_in, 1000);
+ if session.conn and session.conn.setlimit then
+ session.conn:setlimit(opts.bytes_per_second);
+ -- Currently no burst support
+ else
+ session.throttle = throttle.create(opts.bytes_per_second * opts.burst_seconds, opts.burst_seconds);
+ filters.add_filter(session, "bytes/in", filter_set.bytes_in, 1000);
+ end
end
end
@@ -105,3 +110,44 @@ end
function module.unload()
filters.remove_filter_hook(filter_hook);
end
+
+function unlimited(session)
+ local session_type = session.type:match("^[^_]+");
+ if session.conn and session.conn.setlimit then
+ session.conn:setlimit(0);
+ -- Currently no burst support
+ else
+ local filter_set = type_filters[session_type];
+ filters.remove_filter(session, "bytes/in", filter_set.bytes_in);
+ session.throttle = nil;
+ end
+end
+
+function module.add_host(module)
+ local unlimited_jids = module:get_option_inherited_set("unlimited_jids", {});
+
+ if not unlimited_jids:empty() then
+ module:hook("authentication-success", function (event)
+ local session = event.session;
+ local jid = session.username .. "@" .. session.host;
+ if unlimited_jids:contains(jid) then
+ unlimited(session);
+ end
+ end);
+
+ module:hook("s2sout-established", function (event)
+ local session = event.session;
+ if unlimited_jids:contains(session.to_host) then
+ unlimited(session);
+ end
+ end);
+
+ module:hook("s2sin-established", function (event)
+ local session = event.session;
+ if session.from_host and unlimited_jids:contains(session.from_host) then
+ unlimited(session);
+ end
+ end);
+
+ end
+end
diff --git a/plugins/mod_mam/mod_mam.lua b/plugins/mod_mam/mod_mam.lua
index e7d89a95..92b49fcd 100644
--- a/plugins/mod_mam/mod_mam.lua
+++ b/plugins/mod_mam/mod_mam.lua
@@ -1,7 +1,7 @@
-- Prosody IM
-- Copyright (C) 2008-2017 Matthew Wild
-- Copyright (C) 2008-2017 Waqas Hussain
--- Copyright (C) 2011-2017 Kim Alvefur
+-- Copyright (C) 2011-2021 Kim Alvefur
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
@@ -10,6 +10,7 @@
--
local xmlns_mam = "urn:xmpp:mam:2";
+local xmlns_mam_ext = "urn:xmpp:mam:2#extended";
local xmlns_delay = "urn:xmpp:delay";
local xmlns_forward = "urn:xmpp:forward:0";
local xmlns_st_id = "urn:xmpp:sid:0";
@@ -23,8 +24,10 @@ local prefs_to_stanza = module:require"mamprefsxml".tostanza;
local prefs_from_stanza = module:require"mamprefsxml".fromstanza;
local jid_bare = require "util.jid".bare;
local jid_split = require "util.jid".split;
+local jid_resource = require "util.jid".resource;
local jid_prepped_split = require "util.jid".prepped_split;
local dataform = require "util.dataforms".new;
+local get_form_type = require "util.dataforms".get_type;
local host = module.host;
local rm_load_roster = require "core.rostermanager".load_roster;
@@ -33,13 +36,17 @@ local is_stanza = st.is_stanza;
local tostring = tostring;
local time_now = os.time;
local m_min = math.min;
-local timestamp, timestamp_parse, datestamp = import( "util.datetime", "datetime", "parse", "date");
+local timestamp, datestamp = import( "util.datetime", "datetime", "date");
local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
local strip_tags = module:get_option_set("dont_archive_namespaces", { "http://jabber.org/protocol/chatstates" });
local archive_store = module:get_option_string("archive_store", "archive");
local archive = module:open_store(archive_store, "archive");
+local cleanup_after = module:get_option_string("archive_expires_after", "1w");
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000);
+local archive_truncate = math.floor(archive_item_limit * 0.99);
+
if not archive.find then
error("mod_"..(archive._provided_by or archive.name and "storage_"..archive.name).." does not support archiving\n"
.."See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information");
@@ -70,12 +77,22 @@ module:hook("iq/self/"..xmlns_mam..":prefs", function(event)
end);
local query_form = dataform {
- { name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam; };
- { name = "with"; type = "jid-single"; };
- { name = "start"; type = "text-single" };
- { name = "end"; type = "text-single"; };
+ { name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam };
+ { name = "with"; type = "jid-single" };
+ { name = "start"; type = "text-single"; datatype = "xs:dateTime" };
+ { name = "end"; type = "text-single"; datatype = "xs:dateTime" };
};
+if archive.caps and archive.caps.full_id_range then
+ table.insert(query_form, { name = "before-id"; type = "text-single"; });
+ table.insert(query_form, { name = "after-id"; type = "text-single"; });
+end
+
+if archive.caps and archive.caps.ids then
+ table.insert(query_form, { name = "ids"; type = "list-multi"; });
+end
+
+
-- Serve form
module:hook("iq-get/self/"..xmlns_mam..":query", function(event)
local origin, stanza = event.origin, event.stanza;
@@ -95,52 +112,67 @@ module:hook("iq-set/self/"..xmlns_mam..":query", function(event)
get_prefs(origin.username, true);
-- Search query parameters
- local qwith, qstart, qend;
+ local qwith, qstart, qend, qbefore, qafter, qids;
local form = query:get_child("x", "jabber:x:data");
if form then
- local err;
+ local form_type, err = get_form_type(form);
+ if not form_type then
+ origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err));
+ return true;
+ elseif form_type ~= xmlns_mam then
+ origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_mam.."'"));
+ return true;
+ end
form, err = query_form:data(form);
if err then
origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err))));
return true;
end
qwith, qstart, qend = form["with"], form["start"], form["end"];
+ qbefore, qafter = form["before-id"], form["after-id"];
+ qids = form["ids"];
qwith = qwith and jid_bare(qwith); -- dataforms does jidprep
end
- if qstart or qend then -- Validate timestamps
- local vstart, vend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend));
- if (qstart and not vstart) or (qend and not vend) then
- origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp"))
- return true;
- end
- qstart, qend = vstart, vend;
- end
-
- module:log("debug", "Archive query, id %s with %s from %s until %s",
- tostring(qid), qwith or "anyone",
- qstart and timestamp(qstart) or "the dawn of time",
- qend and timestamp(qend) or "now");
-
-- RSM stuff
local qset = rsm.get(query);
local qmax = m_min(qset and qset.max or default_max_items, max_max_items);
local reverse = qset and qset.before or false;
- local before, after = qset and qset.before, qset and qset.after;
+ local before, after = qset and qset.before or qbefore, qset and qset.after or qafter;
if type(before) ~= "string" then before = nil; end
+
+ module:log("debug", "Archive query by %s id=%s with=%s when=%s...%s rsm=%q",
+ origin.username,
+ qid or stanza.attr.id,
+ qwith or "*",
+ qstart and timestamp(qstart) or "",
+ qend and timestamp(qend) or "",
+ qset);
+
+ -- A reverse query needs to be flipped
+ local flip = reverse;
+ -- A flip-page query needs to be the opposite of that.
+ if query:get_child("flip-page") then flip = not flip end
+
-- Load all the data!
local data, err = archive:find(origin.username, {
start = qstart; ["end"] = qend; -- Time range
with = qwith;
limit = qmax == 0 and 0 or qmax + 1;
before = before; after = after;
+ ids = qids;
reverse = reverse;
total = use_total or qmax == 0;
});
if not data then
- origin.send(st.error_reply(stanza, "cancel", "internal-server-error", err));
+ module:log("debug", "Archive query id=%s failed: %s", qid or stanza.attr.id, err);
+ if err == "item-not-found" then
+ origin.send(st.error_reply(stanza, "modify", "item-not-found"));
+ else
+ origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+ end
return true;
end
local total = tonumber(err);
@@ -175,27 +207,64 @@ module:hook("iq-set/self/"..xmlns_mam..":query", function(event)
if not first then first = id; end
last = id;
- if reverse then
+ if flip then
results[count] = fwd_st;
else
origin.send(fwd_st);
end
end
- if reverse then
+ if flip then
for i = #results, 1, -1 do
origin.send(results[i]);
end
+ end
+ if reverse then
first, last = last, first;
end
- -- That's all folks!
- module:log("debug", "Archive query %s completed", tostring(qid));
-
origin.send(st.reply(stanza)
- :tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete })
+ :tag("fin", { xmlns = xmlns_mam, complete = complete })
:add_child(rsm.generate {
first = first, last = last, count = total }));
+
+ -- That's all folks!
+ module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1);
+ return true;
+end);
+
+module:hook("iq-get/self/"..xmlns_mam..":metadata", function (event)
+ local origin, stanza = event.origin, event.stanza;
+
+ local reply = st.reply(stanza):tag("metadata", { xmlns = xmlns_mam });
+
+ do
+ local first = archive:find(origin.username, { limit = 1 });
+ if not first then
+ origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+ return true;
+ end
+
+ local id, _, when = first();
+ if id then
+ reply:tag("start", { id = id, timestamp = timestamp(when) }):up();
+ end
+ end
+
+ do
+ local last = archive:find(origin.username, { limit = 1, reverse = true });
+ if not last then
+ origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+ return true;
+ end
+
+ local id, _, when = last();
+ if id then
+ reply:tag("end", { id = id, timestamp = timestamp(when) }):up();
+ end
+ end
+
+ origin.send(reply);
return true;
end);
@@ -213,13 +282,13 @@ local function shall_store(user, who)
end
local prefs = get_prefs(user);
local rule = prefs[who];
- module:log("debug", "%s's rule for %s is %s", user, who, tostring(rule));
+ module:log("debug", "%s's rule for %s is %s", user, who, rule);
if rule ~= nil then
return rule;
end
-- Below could be done by a metatable
local default = prefs[false];
- module:log("debug", "%s's default rule is %s", user, tostring(default));
+ module:log("debug", "%s's default rule is %s", user, default);
if default == "roster" then
return has_in_roster(user, who);
end
@@ -242,16 +311,84 @@ local function strip_stanza_id(stanza, user)
return stanza;
end
+local function should_store(stanza, c2s) --> boolean, reason: string
+ local st_type = stanza.attr.type or "normal";
+ -- FIXME pass direction of stanza and use that along with bare/full JID addressing
+ -- for more accurate MUC / type=groupchat check
+
+ if st_type == "headline" then
+ -- Headline messages are ephemeral by definition
+ return false, "headline";
+ end
+ if st_type == "error" and not c2s then
+ -- Errors not sent sent from a local client
+ -- Why would a client send an error anyway?
+ if jid_resource(stanza.attr.to) then
+ -- Store delivery failure notifications so you know if your own messages
+ -- were not delivered.
+ return true, "bounce";
+ else
+ -- Skip errors for messages that come from your account, such as PEP
+ -- notifications.
+ return false, "bounce";
+ end
+ end
+ if st_type == "groupchat" then
+ -- MUC messages always go to the full JID, usually archived by the MUC
+ return false, "groupchat";
+ end
+ if stanza:get_child("no-store", "urn:xmpp:hints")
+ or stanza:get_child("no-permanent-store", "urn:xmpp:hints") then
+ -- XXX Experimental XEP
+ return false, "hint";
+ end
+ if stanza:get_child("store", "urn:xmpp:hints") then
+ return true, "hint";
+ end
+ if stanza:get_child("body") then
+ return true, "body";
+ end
+ if stanza:get_child("subject") then
+ -- XXX Who would send a message with a subject but without a body?
+ return true, "subject";
+ end
+ if stanza:get_child("encryption", "urn:xmpp:eme:0") then
+ -- Since we can't know what an encrypted message contains, we assume it's important
+ -- XXX Experimental XEP
+ return true, "encrypted";
+ end
+ if stanza:get_child(nil, "urn:xmpp:receipts") then
+ -- If it's important enough to ask for a receipt then it's important enough to archive
+ -- and the same applies to the receipt
+ return true, "receipt";
+ end
+ if stanza:get_child(nil, "urn:xmpp:chat-markers:0") then
+ -- XXX Experimental XEP
+ return true, "marker";
+ end
+ if stanza:get_child("x", "jabber:x:conference")
+ or stanza:find("{http://jabber.org/protocol/muc#user}x/invite") then
+ return true, "invite";
+ end
+ if stanza:get_child(nil, "urn:xmpp:jingle-message:0") then
+ -- XXX Experimental XEP
+ return true, "jingle call";
+ end
+
+ -- The IM-NG thing to do here would be to return `not st_to_full`
+ -- One day ...
+ return false, "default";
+end
+
-- Handle messages
local function message_handler(event, c2s)
local origin, stanza = event.origin, event.stanza;
local log = c2s and origin.log or module._log;
- local orig_type = stanza.attr.type or "normal";
local orig_from = stanza.attr.from;
local orig_to = stanza.attr.to or orig_from;
-- Stanza without 'to' are treated as if it was to their own bare jid
- -- Whos storage do we put it in?
+ -- Whose storage do we put it in?
local store_user = c2s and origin.username or jid_split(orig_to);
-- And who are they chatting with?
local with = jid_bare(c2s and orig_to or orig_from);
@@ -259,21 +396,12 @@ local function message_handler(event, c2s)
-- Filter out <stanza-id> that claim to be from us
event.stanza = strip_stanza_id(stanza, store_user);
- -- We store chat messages or normal messages that have a body
- if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body")) ) then
- log("debug", "Not archiving stanza: %s (type)", stanza:top_tag());
+ local should, why = should_store(stanza, c2s);
+ if not should then
+ log("debug", "Not archiving stanza: %s (%s)", stanza:top_tag(), why);
return;
end
- -- or if hints suggest we shouldn't
- if not stanza:get_child("store", "urn:xmpp:hints") then -- No hint telling us we should store
- if stanza:get_child("no-permanent-store", "urn:xmpp:hints")
- or stanza:get_child("no-store", "urn:xmpp:hints") then -- Hint telling us we should NOT store
- log("debug", "Not archiving stanza: %s (hint)", stanza:top_tag());
- return;
- end
- end
-
local clone_for_storage;
if not strip_tags:empty() then
clone_for_storage = st.clone(stanza);
@@ -294,10 +422,31 @@ local function message_handler(event, c2s)
-- Check with the users preferences
if shall_store(store_user, with) then
- log("debug", "Archiving stanza: %s", stanza:top_tag());
+ log("debug", "Archiving stanza: %s (%s)", stanza:top_tag(), why);
-- And stash it
- local ok, err = archive:append(store_user, nil, clone_for_storage, time_now(), with);
+ local time = time_now();
+ local ok, err = archive:append(store_user, nil, clone_for_storage, time, with);
+ if not ok and err == "quota-limit" then
+ if type(cleanup_after) == "number" then
+ module:log("debug", "User '%s' over quota, cleaning archive", store_user);
+ local cleaned = archive:delete(store_user, {
+ ["end"] = (os.time() - cleanup_after);
+ });
+ if cleaned then
+ ok, err = archive:append(store_user, nil, clone_for_storage, time, with);
+ end
+ end
+ if not ok and (archive.caps and archive.caps.truncate) then
+ module:log("debug", "User '%s' over quota, truncating archive", store_user);
+ local truncated = archive:delete(store_user, {
+ truncate = archive_truncate;
+ });
+ if truncated then
+ ok, err = archive:append(store_user, nil, clone_for_storage, time, with);
+ end
+ end
+ end
if ok then
local clone_for_other_handlers = st.clone(stanza);
local id = ok;
@@ -325,8 +474,25 @@ end
module:hook("pre-message/bare", strip_stanza_id_after_other_events, -1);
module:hook("pre-message/full", strip_stanza_id_after_other_events, -1);
-local cleanup_after = module:get_option_string("archive_expires_after", "1w");
-local cleanup_interval = module:get_option_number("archive_cleanup_interval", 4 * 60 * 60);
+-- Catch messages not stored by mod_offline and mark them as stored if they
+-- have been archived. This would generally only happen if mod_offline is
+-- disabled. Otherwise the message would generate a delivery failure report,
+-- which would not be accurate because it has been archived.
+module:hook("message/offline/handle", function(event)
+ local stanza = event.stanza;
+ local user = event.username .. "@" .. host;
+ if stanza:get_child_with_attr("stanza-id", xmlns_st_id, "by", user) then
+ return true;
+ end
+end, -2);
+
+-- Don't broadcast offline messages to clients that have queried the archive.
+module:hook("message/offline/broadcast", function (event)
+ if event.origin.mam_requested then
+ return true;
+ end
+end);
+
if cleanup_after ~= "never" then
local cleanup_storage = module:open_store("archive_cleanup");
local cleanup_map = module:open_store("archive_cleanup", "map");
@@ -352,18 +518,41 @@ if cleanup_after ~= "never" then
-- messages, we collect the union of sets of users from dates that fall
-- outside the cleanup range.
- local last_date = require "util.cache".new(module:get_option_number("archive_cleanup_date_cache_size", 1000));
- function schedule_cleanup(username, date)
- date = date or datestamp();
- if last_date:get(username) == date then return end
- local ok = cleanup_map:set(date, username, true);
- if ok then
- last_date:set(username, date);
+ if not (archive.caps and archive.caps.wildcard_delete) then
+ local last_date = require "util.cache".new(module:get_option_number("archive_cleanup_date_cache_size", 1000));
+ function schedule_cleanup(username, date)
+ date = date or datestamp();
+ if last_date:get(username) == date then return end
+ local ok = cleanup_map:set(date, username, true);
+ if ok then
+ last_date:set(username, date);
+ end
end
end
+ local cleanup_time = module:measure("cleanup", "times");
+
local async = require "util.async";
- cleanup_runner = async.runner(function ()
+ module:daily("Remove expired messages", function ()
+ local cleanup_done = cleanup_time();
+
+ if archive.caps and archive.caps.wildcard_delete then
+ local ok, err = archive:delete(true, { ["end"] = os.time() - cleanup_after })
+ if ok then
+ local sum = tonumber(ok);
+ if sum then
+ module:log("info", "Deleted %d expired messages", sum);
+ else
+ -- driver did not tell
+ module:log("info", "Deleted all expired messages");
+ end
+ else
+ module:log("error", "Could not delete messages: %s", err);
+ end
+ cleanup_done();
+ return;
+ end
+
local users = {};
local cut_off = datestamp(os.time() - cleanup_after);
for date in cleanup_storage:users() do
@@ -397,12 +586,9 @@ if cleanup_after ~= "never" then
wait();
end
module:log("info", "Deleted %d expired messages for %d users", sum, num_users);
+ cleanup_done();
end);
- cleanup_task = module:add_timer(1, function ()
- cleanup_runner:run(true);
- return cleanup_interval;
- end);
else
module:log("debug", "Archive expiry disabled");
-- Don't ask the backend to count the potentially unbounded number of items,
@@ -417,8 +603,13 @@ module:hook("pre-message/full", c2s_message_handler, 0);
module:hook("message/bare", message_handler, 0);
module:hook("message/full", message_handler, 0);
+local advertise_extended = archive.caps and archive.caps.full_id_range and archive.caps.ids;
+
module:hook("account-disco-info", function(event)
(event.reply or event.stanza):tag("feature", {var=xmlns_mam}):up();
+ if advertise_extended then
+ (event.reply or event.stanza):tag("feature", {var=xmlns_mam_ext}):up();
+ end
(event.reply or event.stanza):tag("feature", {var=xmlns_st_id}):up();
end);
diff --git a/plugins/mod_message.lua b/plugins/mod_message.lua
index 4b8154e0..9c07e796 100644
--- a/plugins/mod_message.lua
+++ b/plugins/mod_message.lua
@@ -22,6 +22,15 @@ local function process_to_bare(bare, origin, stanza)
if t == "error" then
return true; -- discard
elseif t == "groupchat" then
+ local node, host = jid_split(bare);
+ if user_exists(node, host) then
+ if module:fire_event("message/bare/groupchat", {
+ origin = origin, stanza = stanza;
+ }) then
+ return true;
+ end
+ end
+
origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
elseif t == "headline" then
if user and stanza.attr.to == bare then
@@ -49,8 +58,8 @@ local function process_to_bare(bare, origin, stanza)
local ok
if user_exists(node, host) then
ok = module:fire_event('message/offline/handle', {
- username = node;
- origin = origin,
+ username = node, -- username of the recipient of the offline message
+ origin = origin, -- the sender
stanza = stanza,
});
end
@@ -80,5 +89,3 @@ module:hook("message/bare", function(data)
return process_to_bare(stanza.attr.to or (origin.username..'@'..origin.host), origin, stanza);
end, -1);
-
-module:add_feature("msgoffline");
diff --git a/plugins/mod_mimicking.lua b/plugins/mod_mimicking.lua
new file mode 100644
index 00000000..ab7612cb
--- /dev/null
+++ b/plugins/mod_mimicking.lua
@@ -0,0 +1,86 @@
+-- Prosody IM
+-- Copyright (C) 2012 Florian Zeitz
+-- Copyright (C) 2019 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local encodings = require "util.encodings";
+assert(encodings.confusable, "This module requires that Prosody be built with ICU");
+local skeleton = encodings.confusable.skeleton;
+
+local usage = require "util.prosodyctl".show_usage;
+local usermanager = require "core.usermanager";
+local storagemanager = require "core.storagemanager";
+
+local skeletons
+function module.load()
+ if module.host ~= "*" then
+ skeletons = module:open_store("skeletons");
+ end
+end
+
+module:hook("user-registered", function(user)
+ local skel = skeleton(user.username);
+ local ok, err = skeletons:set(skel, { username = user.username });
+ if not ok then
+ module:log("error", "Unable to store mimicry data (%q => %q): %s", user.username, skel, err);
+ end
+end);
+
+module:hook_global("user-deleted", function(user)
+ if user.host ~= module.host then return end
+ local skel = skeleton(user.username);
+ local ok, err = skeletons:set(skel, nil);
+ if not ok and err then
+ module:log("error", "Unable to clear mimicry data (%q): %s", skel, err);
+ end
+end);
+
+module:hook("user-registering", function(user)
+ local existing, err = skeletons:get(skeleton(user.username));
+ if existing then
+ module:log("debug", "Attempt to register username '%s' which could be confused with '%s'", user.username, existing.username);
+ user.allowed = false;
+ elseif err then
+ module:log("error", "Unable to check if new username '%s' can be confused with any existing user: %s", err);
+ end
+end);
+
+function module.command(arg)
+ if (arg[1] ~= "bootstrap" or not arg[2]) then
+ usage("mod_mimicking bootstrap <host>", "Initialize username mimicry database");
+ return;
+ end
+
+ local host = arg[2];
+
+ local host_session = prosody.hosts[host];
+ if not host_session then
+ return "No such host";
+ end
+
+ storagemanager.initialize_host(host);
+ usermanager.initialize_host(host);
+
+ skeletons = storagemanager.open(host, "skeletons");
+
+ local count = 0;
+ for user in usermanager.users(host) do
+ local skel = skeleton(user);
+ local existing, err = skeletons:get(skel);
+ if existing and existing.username ~= user then
+ module:log("warn", "Existing usernames '%s' and '%s' are confusable", existing.username, user);
+ elseif err then
+ module:log("error", "Error checking for existing mimicry data (%q = %q): %s", user, skel, err);
+ end
+ local ok, err = skeletons:set(skel, { username = user });
+ if ok then
+ count = count + 1;
+ elseif err then
+ module:log("error", "Unable to store mimicry data (%q => %q): %s", user, skel, err);
+ end
+ end
+ module:log("info", "%d usernames indexed", count);
+end
diff --git a/plugins/mod_muc_mam.lua b/plugins/mod_muc_mam.lua
index f0417889..c2026371 100644
--- a/plugins/mod_muc_mam.lua
+++ b/plugins/mod_muc_mam.lua
@@ -1,14 +1,15 @@
-- XEP-0313: Message Archive Management for Prosody MUC
--- Copyright (C) 2011-2017 Kim Alvefur
+-- Copyright (C) 2011-2021 Kim Alvefur
--
-- This file is MIT/X11 licensed.
if module:get_host_type() ~= "component" then
- module:log("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name);
+ module:log_status("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name);
return;
end
local xmlns_mam = "urn:xmpp:mam:2";
+local xmlns_mam_ext = "urn:xmpp:mam:2#extended";
local xmlns_delay = "urn:xmpp:delay";
local xmlns_forward = "urn:xmpp:forward:0";
local xmlns_st_id = "urn:xmpp:sid:0";
@@ -21,6 +22,7 @@ local jid_bare = require "util.jid".bare;
local jid_split = require "util.jid".split;
local jid_prep = require "util.jid".prep;
local dataform = require "util.dataforms".new;
+local get_form_type = require "util.dataforms".get_type;
local mod_muc = module:depends"muc";
local get_room_from_jid = mod_muc.get_room_from_jid;
@@ -29,9 +31,11 @@ local is_stanza = st.is_stanza;
local tostring = tostring;
local time_now = os.time;
local m_min = math.min;
-local timestamp, timestamp_parse, datestamp = import( "util.datetime", "datetime", "parse", "date");
+local timestamp, datestamp = import("util.datetime", "datetime", "date");
local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
+local cleanup_after = module:get_option_string("muc_log_expires_after", "1w");
+
local default_history_length = 20;
local max_history_length = module:get_option_number("max_history_messages", math.huge);
@@ -49,6 +53,9 @@ local log_by_default = module:get_option_boolean("muc_log_by_default", true);
local archive_store = "muc_log";
local archive = module:open_store(archive_store, "archive");
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000);
+local archive_truncate = math.floor(archive_item_limit * 0.99);
+
if archive.name == "null" or not archive.find then
if not archive.find then
module:log("error", "Attempt to open archive storage returned a driver without archive API support");
@@ -63,12 +70,15 @@ end
local function archiving_enabled(room)
if log_all_rooms then
+ module:log("debug", "Archiving all rooms");
return true;
end
local enabled = room._data.archiving;
if enabled == nil then
+ module:log("debug", "Default is %s (for %s)", log_by_default, room.jid);
return log_by_default;
end
+ module:log("debug", "Logging in room %s is %s", room.jid, enabled);
return enabled;
end
@@ -93,10 +103,10 @@ end
-- Note: We ignore the 'with' field as this is internally used for stanza types
local query_form = dataform {
- { name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam; };
- { name = "with"; type = "jid-single"; };
- { name = "start"; type = "text-single" };
- { name = "end"; type = "text-single"; };
+ { name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam };
+ { name = "with"; type = "jid-single" };
+ { name = "start"; type = "text-single"; datatype = "xs:dateTime" };
+ { name = "end"; type = "text-single"; datatype = "xs:dateTime" };
};
-- Serve form
@@ -133,50 +143,64 @@ module:hook("iq-set/bare/"..xmlns_mam..":query", function(event)
-- Search query parameters
local qstart, qend;
+ local qbefore, qafter;
+ local qids;
local form = query:get_child("x", "jabber:x:data");
if form then
- local err;
+ local form_type, err = get_form_type(form);
+ if not form_type then
+ origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err));
+ return true;
+ elseif form_type ~= xmlns_mam then
+ origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_mam.."'"));
+ return true;
+ end
form, err = query_form:data(form);
if err then
origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err))));
return true;
end
qstart, qend = form["start"], form["end"];
+ qbefore, qafter = form["before-id"], form["after-id"];
+ qids = form["ids"];
end
- if qstart or qend then -- Validate timestamps
- local vstart, vend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend))
- if (qstart and not vstart) or (qend and not vend) then
- origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp"))
- return true;
- end
- qstart, qend = vstart, vend;
- end
-
- module:log("debug", "Archive query id %s from %s until %s)",
- tostring(qid),
- qstart and timestamp(qstart) or "the dawn of time",
- qend and timestamp(qend) or "now");
-
-- RSM stuff
local qset = rsm.get(query);
local qmax = m_min(qset and qset.max or default_max_items, max_max_items);
local reverse = qset and qset.before or false;
- local before, after = qset and qset.before, qset and qset.after;
+ local before, after = qset and qset.before or qbefore, qset and qset.after or qafter;
if type(before) ~= "string" then before = nil; end
+ -- A reverse query needs to be flipped
+ local flip = reverse;
+ -- A flip-page query needs to be the opposite of that.
+ if query:get_child("flip-page") then flip = not flip end
+
+ module:log("debug", "Archive query by %s id=%s when=%s...%s rsm=%q",
+ from,
+ qid or stanza.attr.id,
+ qstart and timestamp(qstart) or "",
+ qend and timestamp(qend) or "",
+ qset);
-- Load all the data!
local data, err = archive:find(room_node, {
start = qstart; ["end"] = qend; -- Time range
limit = qmax + 1;
before = before; after = after;
+ ids = qids;
reverse = reverse;
with = "message<groupchat";
});
if not data then
- origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+ module:log("debug", "Archive query id=%s failed: %s", qid or stanza.attr.id, err);
+ if err == "item-not-found" then
+ origin.send(st.error_reply(stanza, "modify", "item-not-found"));
+ else
+ origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+ end
return true;
end
local total = tonumber(err);
@@ -219,27 +243,30 @@ module:hook("iq-set/bare/"..xmlns_mam..":query", function(event)
if not first then first = id; end
last = id;
- if reverse then
+ if flip then
results[count] = fwd_st;
else
origin.send(fwd_st);
end
end
- if reverse then
+ if flip then
for i = #results, 1, -1 do
origin.send(results[i]);
end
+ end
+ if reverse then
first, last = last, first;
end
- -- That's all folks!
- module:log("debug", "Archive query %s completed", tostring(qid));
origin.send(st.reply(stanza)
- :tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete })
+ :tag("fin", { xmlns = xmlns_mam, complete = complete })
:add_child(rsm.generate {
first = first, last = last, count = total }));
+
+ -- That's all folks!
+ module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1);
return true;
end);
@@ -274,7 +301,7 @@ module:hook("muc-get-history", function (event)
local data, err = archive:find(jid_split(room_jid), query);
if not data then
- module:log("error", "Could not fetch history: %s", tostring(err));
+ module:log("error", "Could not fetch history: %s", err);
return
end
@@ -300,7 +327,7 @@ module:hook("muc-get-history", function (event)
maxchars = maxchars - chars;
end
history[i], i = item, i+1;
- -- module:log("debug", tostring(item));
+ -- module:log("debug", item);
end
function event.next_stanza()
i = i - 1;
@@ -325,7 +352,7 @@ end, 1);
-- Handle messages
local function save_to_history(self, stanza)
- local room_node, room_host = jid_split(self.jid);
+ local room_node = jid_split(self.jid);
local stored_stanza = stanza;
@@ -352,7 +379,29 @@ local function save_to_history(self, stanza)
end
-- And stash it
- local id, err = archive:append(room_node, nil, stored_stanza, time_now(), with);
+ local time = time_now();
+ local id, err = archive:append(room_node, nil, stored_stanza, time, with);
+
+ if not id and err == "quota-limit" then
+ if type(cleanup_after) == "number" then
+ module:log("debug", "Room '%s' over quota, cleaning archive", room_node);
+ local cleaned = archive:delete(room_node, {
+ ["end"] = (os.time() - cleanup_after);
+ });
+ if cleaned then
+ id, err = archive:append(room_node, nil, stored_stanza, time, with);
+ end
+ end
+ if not id and (archive.caps and archive.caps.truncate) then
+ module:log("debug", "Room '%s' over quota, truncating archive", room_node);
+ local truncated = archive:delete(room_node, {
+ truncate = archive_truncate;
+ });
+ if truncated then
+ id, err = archive:append(room_node, nil, stored_stanza, time, with);
+ end
+ end
+ end
if id then
schedule_cleanup(room_node);
@@ -390,16 +439,20 @@ end
module:add_feature(xmlns_mam);
+local advertise_extended = archive.caps and archive.caps.full_id_range and archive.caps.ids;
+
module:hook("muc-disco#info", function(event)
- event.reply:tag("feature", {var=xmlns_mam}):up();
+ if archiving_enabled(event.room) then
+ event.reply:tag("feature", {var=xmlns_mam}):up();
+ if advertise_extended then
+ (event.reply or event.stanza):tag("feature", {var=xmlns_mam_ext}):up();
+ end
+ end
event.reply:tag("feature", {var=xmlns_st_id}):up();
end);
-- Cleanup
-local cleanup_after = module:get_option_string("muc_log_expires_after", "1w");
-local cleanup_interval = module:get_option_number("muc_log_cleanup_interval", 4 * 60 * 60);
-
if cleanup_after ~= "never" then
local cleanup_storage = module:open_store("muc_log_cleanup");
local cleanup_map = module:open_store("muc_log_cleanup", "map");
@@ -426,17 +479,40 @@ if cleanup_after ~= "never" then
-- outside the cleanup range.
local last_date = require "util.cache".new(module:get_option_number("muc_log_cleanup_date_cache_size", 1000));
- function schedule_cleanup(roomname, date)
- date = date or datestamp();
- if last_date:get(roomname) == date then return end
- local ok = cleanup_map:set(date, roomname, true);
- if ok then
- last_date:set(roomname, date);
+ if not ( archive.caps and archive.caps.wildcard_delete ) then
+ function schedule_cleanup(roomname, date)
+ date = date or datestamp();
+ if last_date:get(roomname) == date then return end
+ local ok = cleanup_map:set(date, roomname, true);
+ if ok then
+ last_date:set(roomname, date);
+ end
end
end
+ local cleanup_time = module:measure("cleanup", "times");
+
local async = require "util.async";
- cleanup_runner = async.runner(function ()
+ module:daily("Remove expired messages", function ()
+ local cleanup_done = cleanup_time();
+
+ if archive.caps and archive.caps.wildcard_delete then
+ local ok, err = archive:delete(true, { ["end"] = os.time() - cleanup_after })
+ if ok then
+ local sum = tonumber(ok);
+ if sum then
+ module:log("info", "Deleted %d expired messages", sum);
+ else
+ -- driver did not tell
+ module:log("info", "Deleted all expired messages");
+ end
+ else
+ module:log("error", "Could not delete messages: %s", err);
+ end
+ cleanup_done();
+ return;
+ end
+
local rooms = {};
local cut_off = datestamp(os.time() - cleanup_after);
for date in cleanup_storage:users() do
@@ -470,12 +546,9 @@ if cleanup_after ~= "never" then
wait();
end
module:log("info", "Deleted %d expired messages for %d rooms", sum, num_rooms);
+ cleanup_done();
end);
- cleanup_task = module:add_timer(1, function ()
- cleanup_runner:run(true);
- return cleanup_interval;
- end);
else
module:log("debug", "Archive expiry disabled");
end
diff --git a/plugins/mod_net_multiplex.lua b/plugins/mod_net_multiplex.lua
index 8ef77883..ddd58463 100644
--- a/plugins/mod_net_multiplex.lua
+++ b/plugins/mod_net_multiplex.lua
@@ -1,22 +1,38 @@
module:set_global();
+local array = require "util.array";
local max_buffer_len = module:get_option_number("multiplex_buffer_size", 1024);
+local default_mode = module:get_option_number("network_default_read_size", 4096);
local portmanager = require "core.portmanager";
local available_services = {};
+local service_by_protocol = {};
+local available_protocols = array();
local function add_service(service)
local multiplex_pattern = service.multiplex and service.multiplex.pattern;
+ local protocol_name = service.multiplex and service.multiplex.protocol;
+ if protocol_name then
+ module:log("debug", "Adding multiplex service %q with protocol %q", service.name, protocol_name);
+ service_by_protocol[protocol_name] = service;
+ available_protocols:push(protocol_name);
+ end
if multiplex_pattern then
module:log("debug", "Adding multiplex service %q with pattern %q", service.name, multiplex_pattern);
available_services[service] = multiplex_pattern;
- else
+ elseif not protocol_name then
module:log("debug", "Service %q is not multiplex-capable", service.name);
end
end
module:hook("service-added", function (event) add_service(event.service); end);
-module:hook("service-removed", function (event) available_services[event.service] = nil; end);
+module:hook("service-removed", function (event)
+ available_services[event.service] = nil;
+ if event.service.multiplex and event.service.multiplex.protocol then
+ available_protocols:filter(function (p) return p ~= event.service.multiplex.protocol end);
+ service_by_protocol[event.service.multiplex.protocol] = nil;
+ end
+end);
for _, services in pairs(portmanager.get_registered_services()) do
for _, service in ipairs(services) do
@@ -26,9 +42,22 @@ end
local buffers = {};
-local listener = { default_mode = "*a" };
+local listener = { default_mode = max_buffer_len };
-function listener.onconnect()
+function listener.onconnect(conn)
+ local sock = conn:socket();
+ if sock.getalpn then
+ local selected_proto = sock:getalpn();
+ local service = service_by_protocol[selected_proto];
+ if service then
+ module:log("debug", "Routing incoming connection to %s based on ALPN %q", service.name, selected_proto);
+ local next_listener = service.listener;
+ conn:setlistener(next_listener);
+ conn:set_mode(next_listener.default_mode or default_mode);
+ local onconnect = next_listener.onconnect;
+ if onconnect then return onconnect(conn) end
+ end
+ end
end
function listener.onincoming(conn, data)
@@ -40,6 +69,7 @@ function listener.onincoming(conn, data)
module:log("debug", "Routing incoming connection to %s", service.name);
local next_listener = service.listener;
conn:setlistener(next_listener);
+ conn:set_mode(next_listener.default_mode or default_mode);
local onconnect = next_listener.onconnect;
if onconnect then onconnect(conn) end
return next_listener.onincoming(conn, buf);
@@ -68,5 +98,10 @@ module:provides("net", {
name = "multiplex_ssl";
config_prefix = "ssl";
encryption = "ssl";
+ ssl_config = {
+ alpn = function ()
+ return available_protocols;
+ end;
+ };
listener = listener;
});
diff --git a/plugins/mod_offline.lua b/plugins/mod_offline.lua
index 487098d1..dffe8357 100644
--- a/plugins/mod_offline.lua
+++ b/plugins/mod_offline.lua
@@ -24,11 +24,16 @@ module:hook("message/offline/handle", function(event)
node = origin.username;
end
- return offline_messages:append(node, nil, stanza, os.time(), "");
+ local ok = offline_messages:append(node, nil, stanza, os.time(), "");
+ if ok then
+ module:log("debug", "Saved to offline storage: %s", stanza:top_tag());
+ end
+ return ok;
end, -1);
module:hook("message/offline/broadcast", function(event)
local origin = event.origin;
+ origin.log("debug", "Broadcasting offline messages");
local node, host = origin.username, origin.host;
@@ -38,6 +43,9 @@ module:hook("message/offline/broadcast", function(event)
stanza:tag("delay", {xmlns = "urn:xmpp:delay", from = host, stamp = datetime.datetime(when)}):up(); -- XEP-0203
origin.send(stanza);
end
- offline_messages:delete(node);
+ local ok = offline_messages:delete(node);
+ if type(ok) == "number" and ok > 0 then
+ origin.log("debug", "%d offline messages consumed");
+ end
return true;
end, -1);
diff --git a/plugins/mod_pep.lua b/plugins/mod_pep.lua
index 4ee3db5e..f2a25e00 100644
--- a/plugins/mod_pep.lua
+++ b/plugins/mod_pep.lua
@@ -8,6 +8,7 @@ local calculate_hash = require "util.caps".calculate_hash;
local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
local cache = require "util.cache";
local set = require "util.set";
+local new_id = require "util.id".medium;
local storagemanager = require "core.storagemanager";
local usermanager = require "core.usermanager";
@@ -50,6 +51,20 @@ local known_nodes = module:open_store("pep");
local max_max_items = module:get_option_number("pep_max_items", 256);
+local function tonumber_max_items(n)
+ if n == "max" then
+ return max_max_items;
+ end
+ return tonumber(n);
+end
+
+for _, field in ipairs(lib_pubsub.node_config_form) do
+ if field.var == "pubsub#max_items" then
+ field.range_max = max_max_items;
+ break;
+ end
+end
+
function module.save()
return {
recipients = recipients;
@@ -65,7 +80,7 @@ function is_item_stanza(item)
end
function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node 212/actor
- if (new_config["max_items"] or 1) > max_max_items then
+ if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then
return false;
end
if new_config["access_model"] ~= "presence"
@@ -111,14 +126,10 @@ end
local function simple_itemstore(username)
local driver = storagemanager.get_driver(module.host, "pep_data");
return function (config, node)
- if config["persist_items"] then
- module:log("debug", "Creating new persistent item store for user %s, node %q", username, node);
- local archive = driver:open("pep_"..node, "archive");
- return lib_pubsub.archive_itemstore(archive, config, username, node, false);
- else
- module:log("debug", "Creating new ephemeral item store for user %s, node %q", username, node);
- return cache.new(tonumber(config["max_items"]));
- end
+ local max_items = tonumber_max_items(config["max_items"]);
+ module:log("debug", "Creating new persistent item store for user %s, node %q", username, node);
+ local archive = driver:open("pep_"..node, "archive");
+ return lib_pubsub.archive_itemstore(archive, max_items, username, node, false);
end
end
@@ -133,9 +144,6 @@ local function get_broadcaster(username)
if kind == "retract" then
kind = "items"; -- XEP-0060 signals retraction in an <items> container
end
- local message = st.message({ from = user_bare, type = "headline" })
- :tag("event", { xmlns = xmlns_pubsub_event })
- :tag(kind, { node = node });
if item then
item = st.clone(item);
item.attr.xmlns = nil; -- Clear the pubsub namespace
@@ -144,10 +152,19 @@ local function get_broadcaster(username)
item:maptags(function () return nil; end);
end
end
+ end
+
+ local id = new_id();
+ local message = st.message({ from = user_bare, type = "headline", id = id })
+ :tag("event", { xmlns = xmlns_pubsub_event })
+ :tag(kind, { node = node });
+
+ if item then
message:add_child(item);
end
+
for jid in pairs(jids) do
- module:log("debug", "Sending notification to %s from %s: %s", jid, user_bare, tostring(item));
+ module:log("debug", "Sending notification to %s from %s for node %s", jid, user_bare, node);
message.attr.to = jid;
module:send(message);
end
@@ -187,7 +204,6 @@ local nobody_service = pubsub.new({
});
function get_pep_service(username)
- module:log("debug", "get_pep_service(%q)", username);
local user_bare = jid_join(username, host);
local service = services[username];
if service then
@@ -196,13 +212,16 @@ function get_pep_service(username)
if not usermanager.user_exists(username, host) then
return nobody_service;
end
+ module:log("debug", "Creating pubsub service for user %q", username);
service = pubsub.new({
pep_username = username;
node_defaults = {
["max_items"] = 1;
["persist_items"] = true;
["access_model"] = "presence";
+ ["send_last_published_item"] = "on_sub_and_presence";
};
+ max_items = max_max_items;
autocreate_on_publish = true;
autocreate_on_subscribe = false;
@@ -227,6 +246,7 @@ function get_pep_service(username)
end;
};
+ jid = user_bare;
normalize_jid = jid_bare;
check_node_config = check_node_config;
@@ -252,8 +272,6 @@ end
module:hook("iq/bare/"..xmlns_pubsub..":pubsub", handle_pubsub_iq);
module:hook("iq/bare/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq);
-module:add_identity("pubsub", "pep", module:get_option_string("name", "Prosody"));
-module:add_feature("http://jabber.org/protocol/pubsub#publish");
local function get_caps_hash_from_presence(stanza, current)
local t = stanza.attr.type;
@@ -279,6 +297,8 @@ local function get_caps_hash_from_presence(stanza, current)
end
local function resend_last_item(jid, node, service)
+ local ok, config = service:get_node_config(node, true);
+ if ok and config.send_last_published_item ~= "on_sub_and_presence" then return end
local ok, id, item = service:get_last_item(node, jid);
if not (ok and id) then return; end
service.config.broadcaster("items", node, { [jid] = true }, item);
diff --git a/plugins/mod_pep_simple.lua b/plugins/mod_pep_simple.lua
index f0b5d7ef..e686b99b 100644
--- a/plugins/mod_pep_simple.lua
+++ b/plugins/mod_pep_simple.lua
@@ -14,6 +14,7 @@ local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed
local pairs = pairs;
local next = next;
local type = type;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
local calculate_hash = require "util.caps".calculate_hash;
local core_post_stanza = prosody.core_post_stanza;
local bare_sessions = prosody.bare_sessions;
@@ -84,6 +85,7 @@ local function publish_all(user, recipient, session)
if d and notify then
for node in pairs(notify) do
if d[node] then
+ -- luacheck: ignore id
local id, item = unpack(d[node]);
session.send(st.message({from=user, to=recipient, type='headline'})
:tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'})
@@ -229,13 +231,13 @@ module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function(event)
return true;
else --invalid request
session.send(st.error_reply(stanza, 'modify', 'bad-request'));
- module:log("debug", "Invalid request: %s", tostring(payload));
+ module:log("debug", "Invalid request: %s", payload);
return true;
end
else --no presence subscription
session.send(st.error_reply(stanza, 'auth', 'not-authorized')
:tag('presence-subscription-required', {xmlns='http://jabber.org/protocol/pubsub#errors'}));
- module:log("debug", "Unauthorized request: %s", tostring(payload));
+ module:log("debug", "Unauthorized request: %s", payload);
return true;
end
end
diff --git a/plugins/mod_ping.lua b/plugins/mod_ping.lua
index 5fff58d1..b6ccc928 100644
--- a/plugins/mod_ping.lua
+++ b/plugins/mod_ping.lua
@@ -11,23 +11,9 @@ local st = require "util.stanza";
module:add_feature("urn:xmpp:ping");
local function ping_handler(event)
- return event.origin.send(st.reply(event.stanza));
+ event.origin.send(st.reply(event.stanza));
+ return true;
end
module:hook("iq-get/bare/urn:xmpp:ping:ping", ping_handler);
module:hook("iq-get/host/urn:xmpp:ping:ping", ping_handler);
-
--- Ad-hoc command
-
-local datetime = require "util.datetime".datetime;
-
-function ping_command_handler (self, data, state) -- luacheck: ignore 212
- local now = datetime();
- return { info = "Pong\n"..now, status = "completed" };
-end
-
-module:depends "adhoc";
-local adhoc_new = module:require "adhoc".new;
-local descriptor = adhoc_new("Ping", "ping", ping_command_handler);
-module:provides("adhoc", descriptor);
-
diff --git a/plugins/mod_posix.lua b/plugins/mod_posix.lua
index fe826c22..3ef8a632 100644
--- a/plugins/mod_posix.lua
+++ b/plugins/mod_posix.lua
@@ -20,7 +20,6 @@ if not have_signal then
module:log("warn", "Couldn't load signal library, won't respond to SIGTERM");
end
-local format = require "util.format".format;
local lfs = require "lfs";
local stat = lfs.attributes;
@@ -31,39 +30,12 @@ module:set_global(); -- we're a global module
local umask = module:get_option_string("umask", "027");
pposix.umask(umask);
--- Allow switching away from root, some people like strange ports.
-module:hook("server-started", function ()
- local uid = module:get_option("setuid");
- local gid = module:get_option("setgid");
- if gid then
- local success, msg = pposix.setgid(gid);
- if success then
- module:log("debug", "Changed group to %s successfully.", gid);
- else
- module:log("error", "Failed to change group to %s. Error: %s", gid, msg);
- prosody.shutdown("Failed to change group to %s", gid);
- end
- end
- if uid then
- local success, msg = pposix.setuid(uid);
- if success then
- module:log("debug", "Changed user to %s successfully.", uid);
- else
- module:log("error", "Failed to change user to %s. Error: %s", uid, msg);
- prosody.shutdown("Failed to change user to %s", uid);
- end
- end
-end);
-
-- Don't even think about it!
if not prosody.start_time then -- server-starting
- local suid = module:get_option("setuid");
- if not suid or suid == 0 or suid == "root" then
- if pposix.getuid() == 0 and not module:get_option_boolean("run_as_root") then
- module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!");
- module:log("error", "For more information on running Prosody as root, see https://prosody.im/doc/root");
- prosody.shutdown("Refusing to run as root");
- end
+ if pposix.getuid() == 0 and not module:get_option_boolean("run_as_root") then
+ module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!");
+ module:log("error", "For more information on running Prosody as root, see https://prosody.im/doc/root");
+ prosody.shutdown("Refusing to run as root", 1);
end
end
@@ -89,19 +61,19 @@ local function write_pidfile()
pidfile_handle, err = io.open(pidfile, mode);
if not pidfile_handle then
module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
- prosody.shutdown("Couldn't write pidfile");
+ prosody.shutdown("Couldn't write pidfile", 1);
else
if not lfs.lock(pidfile_handle, "w") then -- Exclusive lock
local other_pid = pidfile_handle:read("*a");
module:log("error", "Another Prosody instance seems to be running with PID %s, quitting", other_pid);
pidfile_handle = nil;
- prosody.shutdown("Prosody already running");
+ prosody.shutdown("Prosody already running", 1);
else
pidfile_handle:close();
pidfile_handle, err = io.open(pidfile, "w+");
if not pidfile_handle then
module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
- prosody.shutdown("Couldn't write pidfile");
+ prosody.shutdown("Couldn't write pidfile", 1);
else
if lfs.lock(pidfile_handle, "w") then
pidfile_handle:write(tostring(pposix.getpid()));
@@ -113,24 +85,15 @@ local function write_pidfile()
end
end
-local syslog_opened;
-function syslog_sink_maker(config) -- luacheck: ignore 212/config
- if not syslog_opened then
- pposix.syslog_open("prosody", module:get_option_string("syslog_facility"));
- syslog_opened = true;
- end
- local syslog = pposix.syslog_log;
- return function (name, level, message, ...)
- syslog(level, name, format(message, ...));
- end;
-end
-require "core.loggingmanager".register_sink_type("syslog", syslog_sink_maker);
-
local daemonize = prosody.opts.daemonize;
if daemonize == nil then
-- Fall back to config file if not specified on command-line
- daemonize = module:get_option("daemonize", prosody.installed);
+ daemonize = module:get_option_boolean("daemonize", nil);
+ if daemonize ~= nil then
+ module:log("warn", "The 'daemonize' option has been deprecated, specify -D or -F on the command line instead.");
+ -- TODO: Write some docs and include a link in the warning.
+ end
end
local function remove_log_sinks()
@@ -154,9 +117,7 @@ if daemonize then
write_pidfile();
end
end
- if not prosody.start_time then -- server-starting
- daemonize_server();
- end
+ module:hook("server-started", daemonize_server)
else
-- Not going to daemonize, so write the pid of this process
write_pidfile();
@@ -186,5 +147,20 @@ if have_signal then
prosody.shutdown("Received SIGINT");
prosody.lock_globals();
end);
+
+ signal.signal("SIGUSR1", function ()
+ module:log("info", "Received SIGUSR1");
+ module:fire_event("signal/SIGUSR1");
+ end);
+
+ signal.signal("SIGUSR2", function ()
+ module:log("info", "Received SIGUSR2");
+ module:fire_event("signal/SIGUSR2");
+ end);
end);
end
+
+-- For other modules to reference
+features = {
+ signal_events = true;
+};
diff --git a/plugins/mod_presence.lua b/plugins/mod_presence.lua
index 268a2f0c..3f9a0c12 100644
--- a/plugins/mod_presence.lua
+++ b/plugins/mod_presence.lua
@@ -14,6 +14,7 @@ local s_find = string.find;
local tonumber = tonumber;
local core_post_stanza = prosody.core_post_stanza;
+local core_process_stanza = prosody.core_process_stanza;
local st = require "util.stanza";
local jid_split = require "util.jid".split;
local jid_bare = require "util.jid".bare;
@@ -30,6 +31,14 @@ local recalc_resource_map = require "util.presence".recalc_resource_map;
local ignore_presence_priority = module:get_option_boolean("ignore_presence_priority", false);
+local pre_approval_stream_feature = st.stanza("sub", {xmlns="urn:xmpp:features:pre-approval"});
+module:hook("stream-features", function(event)
+ local origin, features = event.origin, event.features;
+ if origin.username then
+ features:add_child(pre_approval_stream_feature);
+ end
+end);
+
function handle_normal_presence(origin, stanza)
if ignore_presence_priority then
local priority = stanza:get_child("priority");
@@ -81,8 +90,14 @@ function handle_normal_presence(origin, stanza)
res.presence.attr.to = nil;
end
end
- for jid in pairs(roster[false].pending) do -- resend incoming subscription requests
- origin.send(st.presence({type="subscribe", from=jid})); -- TODO add to attribute? Use original?
+ for jid, pending_request in pairs(roster[false].pending) do -- resend incoming subscription requests
+ if type(pending_request) == "table" then
+ local subscribe = st.deserialize(pending_request);
+ subscribe.attr.type, subscribe.attr.from = "subscribe", jid;
+ origin.send(subscribe);
+ else
+ origin.send(st.presence({type="subscribe", from=jid}));
+ end
end
local request = st.presence({type="subscribe", from=origin.username.."@"..origin.host});
for jid, item in pairs(roster) do -- resend outgoing subscription requests
@@ -175,8 +190,10 @@ function handle_outbound_presence_subscriptions_and_probes(origin, stanza, from_
if rostermanager.subscribed(node, host, to_bare) then
rostermanager.roster_push(node, host, to_bare);
end
- core_post_stanza(origin, stanza);
- send_presence_of_available_resources(node, host, to_bare, origin);
+ if rostermanager.is_contact_subscribed(node, host, to_bare) then
+ core_post_stanza(origin, stanza);
+ send_presence_of_available_resources(node, host, to_bare, origin);
+ end
if rostermanager.is_user_subscribed(node, host, to_bare) then
core_post_stanza(origin, st.presence({ type = "probe", from = from_bare, to = to_bare }));
end
@@ -184,6 +201,8 @@ function handle_outbound_presence_subscriptions_and_probes(origin, stanza, from_
-- 1. send unavailable
-- 2. route stanza
-- 3. roster push (subscription = from or both)
+ -- luacheck: ignore 211/pending_in
+ -- Is pending_in meant to be used?
local success, pending_in, subscribed = rostermanager.unsubscribed(node, host, to_bare);
if success then
if subscribed then
@@ -223,10 +242,16 @@ function handle_inbound_presence_subscriptions_and_probes(origin, stanza, from_b
if 0 == send_presence_of_available_resources(node, host, from_bare, origin) then
core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unavailable"}), true); -- TODO send last activity
end
+ elseif rostermanager.is_contact_preapproved(node, host, from_bare) then
+ if not rostermanager.is_contact_pending_in(node, host, from_bare) then
+ if rostermanager.set_contact_pending_in(node, host, from_bare, stanza) then
+ core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="subscribed"}), true);
+ end -- TODO else return error, unable to save
+ end
else
core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unavailable"}), true); -- acknowledging receipt
if not rostermanager.is_contact_pending_in(node, host, from_bare) then
- if rostermanager.set_contact_pending_in(node, host, from_bare) then
+ if rostermanager.set_contact_pending_in(node, host, from_bare, stanza) then
sessionmanager.send_to_available_resources(node, host, stanza);
end -- TODO else return error, unable to save
end
@@ -346,7 +371,7 @@ module:hook("resource-unbind", function(event)
if err then
pres:tag("status"):text("Disconnected: "..err):up();
end
- session:dispatch_stanza(pres);
+ core_process_stanza(session, pres);
elseif session.directed then
local pres = st.presence{ type = "unavailable", from = session.full_jid };
if err then
diff --git a/plugins/mod_proxy65.lua b/plugins/mod_proxy65.lua
index 36614810..069ce0a9 100644
--- a/plugins/mod_proxy65.lua
+++ b/plugins/mod_proxy65.lua
@@ -12,7 +12,6 @@ module:set_global();
local jid_compare, jid_prep = require "util.jid".compare, require "util.jid".prep;
local st = require "util.stanza";
local sha1 = require "util.hashes".sha1;
-local b64 = require "util.encodings".base64.encode;
local server = require "net.server";
local portmanager = require "core.portmanager";
@@ -45,7 +44,7 @@ function listener.onincoming(conn, data)
end -- else error, unexpected input
conn:write("\5\255"); -- send (SOCKS version 5, no acceptable method)
conn:close();
- module:log("debug", "Invalid SOCKS5 greeting received: '%s'", b64(data));
+ module:log("debug", "Invalid SOCKS5 greeting received: %q", data:sub(1, 300));
else -- connection request
--local head = string.char( 0x05, 0x01, 0x00, 0x03, 40 ); -- ( VER=5=SOCKS5, CMD=1=CONNECT, RSV=0=RESERVED, ATYP=3=DOMAIMNAME, SHA-1 size )
if #data == 47 and data:sub(1,5) == "\5\1\0\3\40" and data:sub(-2) == "\0\0" then
@@ -67,7 +66,7 @@ function listener.onincoming(conn, data)
else -- error, unexpected input
conn:write("\5\1\0\3\0\0\0"); -- VER, REP, RSV, ATYP, BND.ADDR (sha), BND.PORT (2 Byte)
conn:close();
- module:log("debug", "Invalid SOCKS5 negotiation received: '%s'", b64(data));
+ module:log("debug", "Invalid SOCKS5 negotiation received: %q", data:sub(1, 300));
end
end
end
@@ -125,7 +124,7 @@ function module.add_host(module)
end
if not allow then
- module:log("warn", "Denying use of proxy for %s", tostring(stanza.attr.from));
+ module:log("warn", "Denying use of proxy for %s", stanza.attr.from);
origin.send(st.error_reply(stanza, "auth", "forbidden"));
return true;
end
diff --git a/plugins/mod_pubsub/mod_pubsub.lua b/plugins/mod_pubsub/mod_pubsub.lua
index c13630c9..4af16a6f 100644
--- a/plugins/mod_pubsub/mod_pubsub.lua
+++ b/plugins/mod_pubsub/mod_pubsub.lua
@@ -39,16 +39,32 @@ end
-- get(node_name)
-- users(): iterator over (node_name)
+local max_max_items = module:get_option_number("pubsub_max_items", 256);
+
+local function tonumber_max_items(n)
+ if n == "max" then
+ return max_max_items;
+ end
+ return tonumber(n);
+end
+
+for _, field in ipairs(lib_pubsub.node_config_form) do
+ if field.var == "pubsub#max_items" then
+ field.range_max = max_max_items;
+ break;
+ end
+end
local node_store = module:open_store(module.name.."_nodes");
-local function create_simple_itemstore(node_config, node_name)
+local function create_simple_itemstore(node_config, node_name) --> util.cache like object
local driver = storagemanager.get_driver(module.host, "pubsub_data");
local archive = driver:open("pubsub_"..node_name, "archive");
- return lib_pubsub.archive_itemstore(archive, node_config, nil, node_name);
+ local max_items = tonumber_max_items(node_config["max_items"]);
+ return lib_pubsub.archive_itemstore(archive, max_items, nil, node_name);
end
-function simple_broadcast(kind, node, jids, item, actor, node_obj)
+function simple_broadcast(kind, node, jids, item, actor, node_obj, service) --luacheck: ignore 431/service
if node_obj then
if node_obj.config["notify_"..kind] == false then
return;
@@ -65,8 +81,10 @@ function simple_broadcast(kind, node, jids, item, actor, node_obj)
if node_obj and node_obj.config.include_payload == false then
item:maptags(function () return nil; end);
end
- if expose_publisher and actor then
- item.attr.publisher = actor
+ if not expose_publisher then
+ item.attr.publisher = nil;
+ elseif not item.attr.publisher then
+ item.attr.publisher = service.config.normalize_jid(actor);
end
end
end
@@ -75,14 +93,13 @@ function simple_broadcast(kind, node, jids, item, actor, node_obj)
local msg_type = node_obj and node_obj.config.notification_type or "headline";
local message = st.message({ from = module.host, type = msg_type, id = id })
:tag("event", { xmlns = xmlns_pubsub_event })
- :tag(kind, { node = node })
+ :tag(kind, { node = node });
if item then
message:add_child(item);
end
local summary;
- -- Compose a sensible textual representation of at least Atom payloads
if item and item.tags[1] then
local payload = item.tags[1];
summary = module:fire_event("pubsub-summary/"..payload.attr.xmlns, {
@@ -100,12 +117,12 @@ function simple_broadcast(kind, node, jids, item, actor, node_obj)
end
end
-local max_max_items = module:get_option_number("pubsub_max_items", 256);
-function check_node_config(node, actor, new_config) -- luacheck: ignore 212/actor 212/node
- if (new_config["max_items"] or 1) > max_max_items then
+function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node 212/actor
+ if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then
return false;
end
- if new_config["access_model"] ~= "whitelist" and new_config["access_model"] ~= "open" then
+ if new_config["access_model"] ~= "whitelist"
+ and new_config["access_model"] ~= "open" then
return false;
end
return true;
@@ -115,6 +132,7 @@ function is_item_stanza(item)
return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item" and #item.tags == 1;
end
+-- Compose a textual representation of Atom payloads
module:hook("pubsub-summary/http://www.w3.org/2005/Atom", function (event)
local payload = event.payload;
local title = payload:get_child_text("title");
@@ -172,6 +190,17 @@ end
function set_service(new_service)
service = new_service;
+ service.config.autocreate_on_publish = autocreate_on_publish;
+ service.config.autocreate_on_subscribe = autocreate_on_subscribe;
+ service.config.expose_publisher = expose_publisher;
+
+ service.config.nodestore = node_store;
+ service.config.itemstore = create_simple_itemstore;
+ service.config.broadcaster = simple_broadcast;
+ service.config.itemcheck = is_item_stanza;
+ service.config.check_node_config = check_node_config;
+ service.config.get_affiliation = get_affiliation;
+
module.environment.service = service;
add_disco_features_from_service(service);
end
@@ -190,7 +219,12 @@ function module.load()
set_service(pubsub.new({
autocreate_on_publish = autocreate_on_publish;
autocreate_on_subscribe = autocreate_on_subscribe;
+ expose_publisher = expose_publisher;
+ node_defaults = {
+ ["persist_items"] = true;
+ };
+ max_items = max_max_items;
nodestore = node_store;
itemstore = create_simple_itemstore;
broadcaster = simple_broadcast;
@@ -198,6 +232,7 @@ function module.load()
check_node_config = check_node_config;
get_affiliation = get_affiliation;
+ jid = module.host;
normalize_jid = jid_bare;
}));
end
diff --git a/plugins/mod_pubsub/pubsub.lib.lua b/plugins/mod_pubsub/pubsub.lib.lua
index 50ef7ddf..83cef808 100644
--- a/plugins/mod_pubsub/pubsub.lib.lua
+++ b/plugins/mod_pubsub/pubsub.lib.lua
@@ -7,6 +7,7 @@ local st = require "util.stanza";
local it = require "util.iterators";
local uuid_generate = require "util.uuid".generate;
local dataform = require"util.dataforms".new;
+local errors = require "util.error";
local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
local xmlns_pubsub_errors = "http://jabber.org/protocol/pubsub#errors";
@@ -31,9 +32,13 @@ local pubsub_errors = {
["internal-server-error"] = { "wait", "internal-server-error" };
["precondition-not-met"] = { "cancel", "conflict", nil, "precondition-not-met" };
["invalid-item"] = { "modify", "bad-request", "invalid item" };
+ ["persistent-items-unsupported"] = { "cancel", "feature-not-implemented", nil, "persistent-items" };
};
local function pubsub_error_reply(stanza, error)
local e = pubsub_errors[error];
+ if not e and errors.is_err(error) then
+ e = { error.type, error.condition, error.text, error.pubsub_condition };
+ end
local reply = st.error_reply(stanza, t_unpack(e, 1, 3));
if e[4] then
reply:tag(e[4], { xmlns = xmlns_pubsub_errors }):up();
@@ -79,8 +84,9 @@ local node_config_form = dataform {
};
{
type = "text-single";
- datatype = "xs:integer";
+ datatype = "pubsub:integer-or-max";
name = "max_items";
+ range_min = 1;
var = "pubsub#max_items";
label = "Max # of items to persist";
};
@@ -115,6 +121,12 @@ local node_config_form = dataform {
};
};
{
+ type = "list-single";
+ var = "pubsub#send_last_published_item";
+ name = "send_last_published_item";
+ options = { "never"; "on_sub"; "on_sub_and_presence" };
+ };
+ {
type = "boolean";
value = true;
label = "Whether to deliver event notifications";
@@ -153,6 +165,7 @@ local node_config_form = dataform {
value = true;
};
};
+_M.node_config_form = node_config_form;
local subscribe_options_form = dataform {
{
@@ -166,6 +179,7 @@ local subscribe_options_form = dataform {
label = "Receive message body in addition to payload?";
};
};
+_M.subscribe_options_form = subscribe_options_form;
local node_metadata_form = dataform {
{
@@ -185,7 +199,16 @@ local node_metadata_form = dataform {
type = "text-single";
name = "pubsub#type";
};
+ {
+ type = "text-single";
+ name = "pubsub#access_model";
+ };
+ {
+ type = "text-single";
+ name = "pubsub#publish_model";
+ };
};
+_M.node_metadata_form = node_metadata_form;
local service_method_feature_map = {
add_subscription = { "subscribe", "subscription-options" };
@@ -237,19 +260,32 @@ function _M.get_feature_set(service)
supported_features:add("access-"..service.node_defaults.access_model);
end
+ if service.node_defaults.send_last_published_item ~= "never" then
+ supported_features:add("last-published");
+ end
+
if rawget(service.config, "itemstore") and rawget(service.config, "nodestore") then
supported_features:add("persistent-items");
end
+ if true --[[ node_metadata_form[max_items].datatype == "pubsub:integer-or-max" ]] then
+ supported_features:add("config-node-max");
+ end
+
return supported_features;
end
function _M.handle_disco_info_node(event, service)
local stanza, reply, node = event.stanza, event.reply, event.node;
local ok, ret = service:get_nodes(stanza.attr.from);
+ if not ok then
+ event.origin.send(pubsub_error_reply(stanza, ret));
+ return true;
+ end
local node_obj = ret[node];
- if not ok or not node_obj then
- return;
+ if not node_obj then
+ event.origin.send(pubsub_error_reply(stanza, "item-not-found"));
+ return true;
end
event.exists = true;
reply:tag("identity", { category = "pubsub", type = "leaf" }):up();
@@ -258,6 +294,8 @@ function _M.handle_disco_info_node(event, service)
["pubsub#title"] = node_obj.config.title;
["pubsub#description"] = node_obj.config.description;
["pubsub#type"] = node_obj.config.payload_type;
+ ["pubsub#access_model"] = node_obj.config.access_model;
+ ["pubsub#publish_model"] = node_obj.config.publish_model;
}, "result"));
end
end
@@ -266,11 +304,12 @@ function _M.handle_disco_items_node(event, service)
local stanza, reply, node = event.stanza, event.reply, event.node;
local ok, ret = service:get_items(node, stanza.attr.from);
if not ok then
- return;
+ event.origin.send(pubsub_error_reply(stanza, ret));
+ return true;
end
for _, id in ipairs(ret) do
- reply:tag("item", { jid = module.host, name = id }):up();
+ reply:tag("item", { jid = service.jid or module.host, name = id }):up();
end
event.exists = true;
end
@@ -308,24 +347,36 @@ function handlers.get_items(origin, stanza, items, service)
origin.send(pubsub_error_reply(stanza, "nodeid-required"));
return true;
end
- local ok, results = service:get_items(node, stanza.attr.from, requested_items);
+ local resultspec; -- TODO rsm.get()
+ if items.attr.max_items then
+ resultspec = { max = tonumber(items.attr.max_items) };
+ end
+ local ok, results = service:get_items(node, stanza.attr.from, requested_items, resultspec);
if not ok then
origin.send(pubsub_error_reply(stanza, results));
return true;
end
+ local expose_publisher = service.config.expose_publisher;
+
local data = st.stanza("items", { node = node });
- for _, id in ipairs(results) do
- data:add_child(results[id]);
+ local iter, v, i = ipairs(results);
+ if not requested_items then
+ -- XXX Hack to preserve order of explicitly requested items.
+ iter, v, i = it.reverse(iter, v, i);
end
- local reply;
- if data then
- reply = st.reply(stanza)
- :tag("pubsub", { xmlns = xmlns_pubsub })
- :add_child(data);
- else
- reply = pubsub_error_reply(stanza, "item-not-found");
+
+ for _, id in iter, v, i do
+ local item = results[id];
+ if not expose_publisher then
+ item = st.clone(item);
+ item.attr.publisher = nil;
+ end
+ data:add_child(item);
end
+ local reply = st.reply(stanza)
+ :tag("pubsub", { xmlns = xmlns_pubsub })
+ :add_child(data);
origin.send(reply);
return true;
end
@@ -496,6 +547,12 @@ function handlers.set_subscribe(origin, stanza, subscribe, service)
reply = pubsub_error_reply(stanza, ret);
end
origin.send(reply);
+ local ok, config = service:get_node_config(node, true);
+ if ok and config.send_last_published_item ~= "never" then
+ local ok, id, item = service:get_last_item(node, jid);
+ if not (ok and id) then return; end
+ service.config.broadcaster("items", node, { [jid] = true }, item);
+ end
end
function handlers.set_unsubscribe(origin, stanza, unsubscribe, service)
@@ -508,7 +565,13 @@ function handlers.set_unsubscribe(origin, stanza, unsubscribe, service)
local ok, ret = service:remove_subscription(node, stanza.attr.from, jid);
local reply;
if ok then
- reply = st.reply(stanza);
+ reply = st.reply(stanza)
+ :tag("pubsub", { xmlns = xmlns_pubsub })
+ :tag("subscription", {
+ node = node,
+ jid = jid,
+ subscription = "none"
+ }):up();
else
reply = pubsub_error_reply(stanza, ret);
end
@@ -592,6 +655,9 @@ function handlers.set_publish(origin, stanza, publish, service)
item.attr.id = id;
end
end
+ if item then
+ item.attr.publisher = service.config.normalize_jid(stanza.attr.from);
+ end
local ok, ret = service:publish(node, stanza.attr.from, id, item, required_config);
local reply;
if ok then
@@ -633,14 +699,13 @@ function handlers.set_retract(origin, stanza, retract, service)
end
function handlers.owner_set_purge(origin, stanza, purge, service)
- local node, notify = purge.attr.node, purge.attr.notify;
- notify = (notify == "1") or (notify == "true");
+ local node = purge.attr.node;
local reply;
if not node then
origin.send(pubsub_error_reply(stanza, "nodeid-required"));
return true;
end
- local ok, ret = service:purge(node, stanza.attr.from, notify);
+ local ok, ret = service:purge(node, stanza.attr.from, true);
if ok then
reply = st.reply(stanza);
else
@@ -781,16 +846,15 @@ function handlers.owner_set_affiliations(origin, stanza, affiliations, service)
return true;
end
-local function create_encapsulating_item(id, payload)
- local item = st.stanza("item", { id = id, xmlns = xmlns_pubsub });
+local function create_encapsulating_item(id, payload, publisher)
+ local item = st.stanza("item", { id = id, publisher = publisher, xmlns = xmlns_pubsub });
item:add_child(payload);
return item;
end
-local function archive_itemstore(archive, config, user, node)
- module:log("debug", "Creation of itemstore for node %s with config %s", node, config);
+local function archive_itemstore(archive, max_items, user, node)
+ module:log("debug", "Creation of archive itemstore for node %s with limit %d", node, max_items);
local get_set = {};
- local max_items = config["max_items"];
function get_set:items() -- luacheck: ignore 212/self
local data, err = archive:find(user, {
limit = tonumber(max_items);
@@ -801,14 +865,15 @@ local function archive_itemstore(archive, config, user, node)
return true;
end
module:log("debug", "Listed items %s", data);
- return it.reverse(function()
+ return function()
+ -- luacheck: ignore 211/when
local id, payload, when, publisher = data();
if id == nil then
return;
end
local item = create_encapsulating_item(id, payload, publisher);
return id, item;
- end);
+ end;
end
function get_set:get(key) -- luacheck: ignore 212/self
local data, err = archive:find(user, {
@@ -863,7 +928,7 @@ local function archive_itemstore(archive, config, user, node)
return item.attr.id, item;
end
end
- return setmetatable(get_set, archive);
+ return get_set;
end
_M.archive_itemstore = archive_itemstore;
diff --git a/plugins/mod_register.lua b/plugins/mod_register.lua
index 763e2fd9..bec3fb5a 100644
--- a/plugins/mod_register.lua
+++ b/plugins/mod_register.lua
@@ -11,6 +11,7 @@ local allow_registration = module:get_option_boolean("allow_registration", false
if allow_registration then
module:depends("register_ibr");
+ module:depends("watchregistrations");
end
module:depends("user_account_management");
diff --git a/plugins/mod_register_ibr.lua b/plugins/mod_register_ibr.lua
index e04e6ecd..83d284c8 100644
--- a/plugins/mod_register_ibr.lua
+++ b/plugins/mod_register_ibr.lua
@@ -9,10 +9,12 @@
local st = require "util.stanza";
local dataform_new = require "util.dataforms".new;
-local usermanager_user_exists = require "core.usermanager".user_exists;
-local usermanager_create_user = require "core.usermanager".create_user;
-local usermanager_delete_user = require "core.usermanager".delete_user;
+local usermanager_user_exists = require "core.usermanager".user_exists;
+local usermanager_create_user = require "core.usermanager".create_user;
+local usermanager_set_password = require "core.usermanager".create_user;
+local usermanager_delete_user = require "core.usermanager".delete_user;
local nodeprep = require "util.encodings".stringprep.nodeprep;
+local util_error = require "util.error";
local additional_fields = module:get_option("additional_registration_fields", {});
local require_encryption = module:get_option_boolean("c2s_require_encryption",
@@ -155,7 +157,7 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event)
return true;
end
- local username, password = nodeprep(data.username), data.password;
+ local username, password = nodeprep(data.username, true), data.password;
data.username, data.password = nil, nil;
local host = module.host;
if not username or username == "" then
@@ -167,25 +169,44 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event)
local user = { username = username, password = password, host = host, additional = data, ip = session.ip, session = session, allowed = true }
module:fire_event("user-registering", user);
if not user.allowed then
- log("debug", "Registration disallowed by module: %s", user.reason or "no reason given");
- session.send(st.error_reply(stanza, "modify", "not-acceptable", user.reason));
+ local error_type, error_condition, reason;
+ local err = user.error;
+ if err then
+ error_type, error_condition, reason = err.type, err.condition, err.text;
+ else
+ -- COMPAT pre-util.error
+ error_type, error_condition, reason = user.error_type, user.error_condition, user.reason;
+ end
+ log("debug", "Registration disallowed by module: %s", reason or "no reason given");
+ session.send(st.error_reply(stanza, error_type or "modify", error_condition or "not-acceptable", reason));
return true;
end
if usermanager_user_exists(username, host) then
- log("debug", "Attempt to register with existing username");
- session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists."));
- return true;
+ if user.allow_reset == username then
+ local ok, err = util_error.coerce(usermanager_set_password(username, password, host));
+ if ok then
+ module:fire_event("user-password-reset", user);
+ session.send(st.reply(stanza)); -- reset ok!
+ else
+ session.log("error", "Unable to reset password for %s@%s: %s", username, host, err);
+ session.send(st.error_reply(stanza, err.type, err.condition, err.text));
+ end
+ return true;
+ else
+ log("debug", "Attempt to register with existing username");
+ session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists."));
+ return true;
+ end
end
- -- TODO unable to write file, file may be locked, etc, what's the correct error?
- local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk.");
- if usermanager_create_user(username, password, host) then
+ local created, err = usermanager_create_user(username, password, host);
+ if created then
data.registered = os.time();
if not account_details:set(username, data) then
log("debug", "Could not store extra details");
usermanager_delete_user(username, host);
- session.send(error_reply);
+ session.send(st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk."));
return true;
end
session.send(st.reply(stanza)); -- user created!
@@ -194,8 +215,8 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event)
username = username, host = host, source = "mod_register",
session = session });
else
- log("debug", "Could not create user");
- session.send(error_reply);
+ log("debug", "Could not create user", err);
+ session.send(st.error_reply(stanza, "cancel", "feature-not-implemented", err));
end
return true;
end);
diff --git a/plugins/mod_register_limits.lua b/plugins/mod_register_limits.lua
index 736282a5..cb430f7f 100644
--- a/plugins/mod_register_limits.lua
+++ b/plugins/mod_register_limits.lua
@@ -13,21 +13,24 @@ 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 errors = require "util.error";
+-- COMPAT drop old option names
local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations");
-local whitelist_only = module:get_option_boolean("whitelist_registration_only");
-local whitelisted_ips = module:get_option_set("registration_whitelist", { "127.0.0.1", "::1" })._items;
-local blacklisted_ips = module:get_option_set("registration_blacklist", {})._items;
+local allowlist_only = module:get_option_boolean("allowlist_registration_only", module:get_option_boolean("whitelist_registration_only"));
+local allowlisted_ips = module:get_option_set("registration_allowlist", module:get_option("registration_whitelist", { "127.0.0.1", "::1" }))._items;
+local blocklisted_ips = module:get_option_set("registration_blocklist", module:get_option_set("registration_blacklist", {}))._items;
local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1);
local throttle_period = module:get_option_number("registration_throttle_period", min_seconds_between_registrations);
local throttle_cache_size = module:get_option_number("registration_throttle_cache_size", 100);
-local blacklist_overflow = module:get_option_boolean("blacklist_on_registration_throttle_overload", false);
+local blocklist_overflow = module:get_option_boolean("blocklist_on_registration_throttle_overload",
+ module:get_option_boolean("blacklist_on_registration_throttle_overload", false));
-local throttle_cache = new_cache(throttle_cache_size, blacklist_overflow and function (ip, throttle)
+local throttle_cache = new_cache(throttle_cache_size, blocklist_overflow and function (ip, throttle)
if not throttle:peek() then
- module:log("info", "Adding ip %s to registration blacklist", ip);
- blacklisted_ips[ip] = true;
+ module:log("info", "Adding ip %s to registration blocklist", ip);
+ blocklisted_ips[ip] = true;
end
end or nil);
@@ -54,25 +57,49 @@ local function ip_in_set(set, ip)
return false;
end
+local err_registry = {
+ blocklisted = {
+ text = "Your IP address is blocklisted";
+ type = "auth";
+ condition = "forbidden";
+ };
+ not_allowlisted = {
+ text = "Your IP address is not allowlisted";
+ type = "auth";
+ condition = "forbidden";
+ };
+ throttled = {
+ text = "Too many registrations from this IP address recently";
+ type = "wait";
+ condition = "policy-violation";
+ };
+}
+
module:hook("user-registering", function (event)
local session = event.session;
local ip = event.ip or session and session.ip;
local log = session and session.log or module._log;
if not ip then
- log("warn", "IP not known; can't apply blacklist/whitelist");
- elseif ip_in_set(blacklisted_ips, ip) then
- log("debug", "Registration disallowed by blacklist");
+ log("warn", "IP not known; can't apply blocklist/allowlist");
+ elseif ip_in_set(blocklisted_ips, ip) then
+ log("debug", "Registration disallowed by blocklist");
event.allowed = false;
- event.reason = "Your IP address is blacklisted";
- elseif (whitelist_only and not ip_in_set(whitelisted_ips, ip)) then
- log("debug", "Registration disallowed by whitelist");
+ event.error = errors.new("blocklisted", event, err_registry);
+ elseif (allowlist_only and not ip_in_set(allowlisted_ips, ip)) then
+ log("debug", "Registration disallowed by allowlist");
event.allowed = false;
- event.reason = "Your IP address is not whitelisted";
- elseif throttle_max and not ip_in_set(whitelisted_ips, ip) then
+ event.error = errors.new("not_allowlisted", event, err_registry);
+ elseif throttle_max and not ip_in_set(allowlisted_ips, ip) then
if not check_throttle(ip) then
log("debug", "Registrations over limit for ip %s", ip or "?");
event.allowed = false;
- event.reason = "Too many registrations from this IP address recently";
+ event.error = errors.new("throttled", event, err_registry);
end
end
+ if event.error then
+ -- COMPAT pre-util.error
+ event.reason = event.error.text;
+ event.error_type = event.error.type;
+ event.error_condition = event.error.condition;
+ end
end);
diff --git a/plugins/mod_roster.lua b/plugins/mod_roster.lua
index 39d59cbd..37fa197a 100644
--- a/plugins/mod_roster.lua
+++ b/plugins/mod_roster.lua
@@ -10,6 +10,7 @@
local st = require "util.stanza"
local jid_split = require "util.jid".split;
+local jid_resource = require "util.jid".resource;
local jid_prep = require "util.jid".prep;
local tonumber = tonumber;
local pairs = pairs;
@@ -66,15 +67,14 @@ module:hook("iq/self/jabber:iq:roster:query", function(event)
local item = query.tags[1];
local from_node, from_host = jid_split(stanza.attr.from);
local jid = jid_prep(item.attr.jid);
- local node, host, resource = jid_split(jid);
- if not resource and host then
+ if jid and not jid_resource(jid) then
if jid ~= from_node.."@"..from_host then
if item.attr.subscription == "remove" then
local roster = session.roster;
local r_item = roster[jid];
if r_item then
module:fire_event("roster-item-removed", {
- username = node, jid = jid, item = r_item, origin = session, roster = roster,
+ username = from_node, jid = jid, item = r_item, origin = session, roster = roster,
});
local success, err_type, err_cond, err_msg = rm_remove_from_roster(session, jid);
if success then
diff --git a/plugins/mod_s2s/mod_s2s.lua b/plugins/mod_s2s.lua
index c3de28db..7b915194 100644
--- a/plugins/mod_s2s/mod_s2s.lua
+++ b/plugins/mod_s2s.lua
@@ -17,6 +17,7 @@ local t_insert = table.insert;
local traceback = debug.traceback;
local add_task = require "util.timer".add_task;
+local stop_timer = require "util.timer".stop;
local st = require "util.stanza";
local initialize_filters = require "util.filters".initialize;
local nameprep = require "util.encodings".stringprep.nameprep;
@@ -25,10 +26,11 @@ local s2s_new_incoming = require "core.s2smanager".new_incoming;
local s2s_new_outgoing = require "core.s2smanager".new_outgoing;
local s2s_destroy_session = require "core.s2smanager".destroy_session;
local uuid_gen = require "util.uuid".generate;
-local fire_global_event = prosody.events.fire_event;
local runner = require "util.async".runner;
-
-local s2sout = module:require("s2sout");
+local connect = require "net.connect".connect;
+local service = require "net.resolvers.service";
+local errors = require "util.error";
+local set = require "util.set";
local connect_timeout = module:get_option_number("s2s_timeout", 90);
local stream_close_timeout = module:get_option_number("s2s_close_timeout", 5);
@@ -39,26 +41,77 @@ local secure_domains, insecure_domains =
local require_encryption = module:get_option_boolean("s2s_require_encryption", false);
local stanza_size_limit = module:get_option_number("s2s_stanza_size_limit", 1024*512);
-local measure_connections = module:measure("connections", "amount");
-local measure_ipv6 = module:measure("ipv6", "amount");
+local measure_connections_inbound = module:metric(
+ "gauge", "connections_inbound", "",
+ "Established incoming s2s connections",
+ {"host", "type", "ip_family"}
+);
+local measure_connections_outbound = module:metric(
+ "gauge", "connections_outbound", "",
+ "Established outgoing s2s connections",
+ {"host", "type", "ip_family"}
+);
+
+local m_accepted_tcp_connections = module:metric(
+ "counter", "accepted_tcp", "",
+ "Accepted incoming connections on the TCP layer"
+);
+local m_authn_connections = module:metric(
+ "counter", "authenticated", "",
+ "Authenticated incoming connections",
+ {"host", "direction", "mechanism"}
+);
+local m_initiated_connections = module:metric(
+ "counter", "initiated", "",
+ "Initiated outbound connections",
+ {"host"}
+);
+local m_closed_connections = module:metric(
+ "counter", "closed", "",
+ "Closed connections",
+ {"host", "direction", "error"}
+);
+local m_tls_params = module:metric(
+ "counter", "encrypted", "",
+ "Encrypted connections",
+ {"protocol"; "cipher"}
+);
local sessions = module:shared("sessions");
local runner_callbacks = {};
+local listener = {};
+
local log = module._log;
+local s2s_service_options = {
+ default_port = 5269;
+ use_ipv4 = module:get_option_boolean("use_ipv4", true);
+ use_ipv6 = module:get_option_boolean("use_ipv6", true);
+ use_dane = module:get_option_boolean("use_dane", false);
+};
+local s2s_service_options_mt = { __index = s2s_service_options }
+
module:hook("stats-update", function ()
- local count = 0;
- local ipv6 = 0;
+ measure_connections_inbound:clear()
+ measure_connections_outbound:clear()
+ -- TODO: init all expected metrics once?
+ -- or maybe create/delete them in host-activate/host-deactivate? requires
+ -- extra API in openmetrics.lua tho
for _, session in pairs(sessions) do
- count = count + 1;
- if session.ip and session.ip:match(":") then
- ipv6 = ipv6 + 1;
- end
+ local is_inbound = string.sub(session.type, 4, 5) == "in"
+ local metric_family = is_inbound and measure_connections_inbound or measure_connections_outbound
+ local host = is_inbound and session.to_host or session.from_host or ""
+ local type_ = session.type or "other"
+
+ -- we want to expose both v4 and v6 counters in all cases to make
+ -- queries smoother
+ local is_ipv6 = session.ip and session.ip:match(":") and 1 or 0
+ local is_ipv4 = 1 - is_ipv6
+ metric_family:with_labels(host, type_, "ipv4"):add(is_ipv4)
+ metric_family:with_labels(host, type_, "ipv6"):add(is_ipv6)
end
- measure_connections(count);
- measure_ipv6(ipv6);
end);
--- Handle stanzas to remote domains
@@ -78,15 +131,28 @@ local function bounce_sendq(session, reason)
(session.log or log)("error", "Attempting to close the dummy origin of s2s error replies, please report this! Traceback: %s", traceback());
end;
};
+ -- FIXME Allow for more specific error conditions
+ -- TODO use util.error ?
+ local error_type = "cancel";
+ local condition = "remote-server-not-found";
+ local reason_text;
+ if session.had_stream then -- set when a stream is opened by the remote
+ error_type, condition = "wait", "remote-server-timeout";
+ end
+ if errors.is_err(reason) then
+ error_type, condition, reason_text = reason.type, reason.condition, reason.text;
+ elseif type(reason) == "string" then
+ reason_text = reason;
+ end
for i, data in ipairs(sendq) do
local reply = data[2];
if reply and not(reply.attr.xmlns) and bouncy_stanzas[reply.name] then
reply.attr.type = "error";
- reply:tag("error", {type = "cancel", by = session.from_host})
- :tag("remote-server-not-found", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up();
- if reason then
+ reply:tag("error", {type = error_type, by = session.from_host})
+ :tag(condition, {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up();
+ if reason_text then
reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"})
- :text("Server-to-server connection failed: "..reason):up();
+ :text("Server-to-server connection failed: "..reason_text):up();
end
core_process_stanza(dummy, reply);
end
@@ -107,38 +173,33 @@ function route_to_existing_session(event)
return false;
end
local host = hosts[from_host].s2sout[to_host];
- if host then
- -- We have a connection to this host already
- if host.type == "s2sout_unauthed" and (stanza.name ~= "db:verify" or not host.dialback_key) then
- (host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host);
-
- -- Queue stanza until we are able to send it
- local queued_item = {
- tostring(stanza),
- stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza);
- };
- if host.sendq then
- t_insert(host.sendq, queued_item);
- else
- -- luacheck: ignore 122
- host.sendq = { queued_item };
- end
- host.log("debug", "stanza [%s] queued ", stanza.name);
- return true;
- elseif host.type == "local" or host.type == "component" then
- log("error", "Trying to send a stanza to ourselves??")
- log("error", "Traceback: %s", traceback());
- log("error", "Stanza: %s", tostring(stanza));
- return false;
+ if not host then return end
+
+ -- We have a connection to this host already
+ if host.type == "s2sout_unauthed" and (stanza.name ~= "db:verify" or not host.dialback_key) then
+ (host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host);
+
+ -- Queue stanza until we are able to send it
+ local queued_item = {
+ tostring(stanza),
+ stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza);
+ };
+ if host.sendq then
+ t_insert(host.sendq, queued_item);
else
- -- FIXME
- if host.from_host ~= from_host then
- log("error", "WARNING! This might, possibly, be a bug, but it might not...");
- log("error", "We are going to send from %s instead of %s", host.from_host, from_host);
- end
- if host.sends2s(stanza) then
- return true;
- end
+ -- luacheck: ignore 122
+ host.sendq = { queued_item };
+ end
+ host.log("debug", "stanza [%s] queued ", stanza.name);
+ return true;
+ elseif host.type == "local" or host.type == "component" then
+ log("error", "Trying to send a stanza to ourselves??")
+ log("error", "Traceback: %s", traceback());
+ log("error", "Stanza: %s", stanza);
+ return false;
+ else
+ if host.sends2s(stanza) then
+ return true;
end
end
end
@@ -148,17 +209,17 @@ function route_to_new_session(event)
local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
log("debug", "opening a new outgoing connection for this stanza");
local host_session = s2s_new_outgoing(from_host, to_host);
+ host_session.version = 1;
-- 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)} };
- log("debug", "stanza [%s] queued until connection complete", tostring(stanza.name));
- s2sout.initiate_connection(host_session);
- if (not host_session.connecting) and (not host_session.conn) then
- log("warn", "Connection to %s failed already, destroying session...", to_host);
- s2s_destroy_session(host_session, "Connection failed");
- return false;
- end
+ log("debug", "stanza [%s] queued until connection complete", stanza.name);
+ -- FIXME Cleaner solution to passing extra data from resolvers to net.server
+ -- This mt-clone allows resolvers to add extra data, currently used for DANE TLSA records
+ local extra = setmetatable({}, s2s_service_options_mt);
+ connect(service.new(to_host, "xmpp-server", "tcp", extra), listener, nil, { session = host_session });
+ m_initiated_connections:with_labels(from_host):add(1)
return true;
end
@@ -186,12 +247,31 @@ function module.add_host(module)
-- so the stream is ready for stanzas. RFC 6120 Section 4.3
mark_connected(session);
return true;
+ elseif require_encryption and not session.secure then
+ session.log("warn", "Encrypted server-to-server communication is required but was not offered by %s", session.to_host);
+ session:close({
+ condition = "policy-violation",
+ text = "Encrypted server-to-server communication is required but was not offered",
+ }, nil, "Could not establish encrypted connection to remote server");
+ return true;
elseif not session.dialback_verifying then
session.log("warn", "No SASL EXTERNAL offer and Dialback doesn't seem to be enabled, giving up");
- session:close();
- return false;
+ session:close({
+ condition = "unsupported-feature",
+ text = "No viable authentication method offered",
+ }, nil, "No viable authentication method offered by remote server");
+ return true;
end
end, -1);
+
+ function module.unload()
+ if module.reloading then return end
+ for _, session in pairs(sessions) do
+ if session.to_host == module.host or session.from_host == module.host then
+ session:close("host-gone");
+ end
+ end
+ end
end
-- Stream is authorised, and ready for normal stanzas
@@ -205,16 +285,27 @@ function mark_connected(session)
local event_data = { session = session };
if session.type == "s2sout" then
- fire_global_event("s2sout-established", event_data);
- hosts[from].events.fire_event("s2sout-established", event_data);
+ module:fire_event("s2sout-established", event_data);
+ module:context(from):fire_event("s2sout-established", event_data);
+
+ if session.incoming then
+ session.send = function(stanza)
+ return module:context(from):fire_event("route/remote", { from_host = from, to_host = to, stanza = stanza });
+ end;
+ end
+
else
+ if session.outgoing and not hosts[to].s2sout[from] then
+ session.log("debug", "Setting up to handle route from %s to %s", to, from);
+ hosts[to].s2sout[from] = session; -- luacheck: ignore 122
+ end
local host_session = hosts[to];
session.send = function(stanza)
return host_session.events.fire_event("route/remote", { from_host = to, to_host = from, stanza = stanza });
end;
- fire_global_event("s2sin-established", event_data);
- hosts[to].events.fire_event("s2sin-established", event_data);
+ module:fire_event("s2sin-established", event_data);
+ module:context(to):fire_event("s2sin-established", event_data);
end
if session.direction == "outgoing" then
@@ -227,13 +318,11 @@ function mark_connected(session)
end
session.sendq = nil;
end
+ end
- if session.resolver then
- session.resolver._resolver:closeall()
- end
- session.resolver = nil;
- session.ip_hosts = nil;
- session.srv_hosts = nil;
+ if session.connect_timeout then
+ stop_timer(session.connect_timeout);
+ session.connect_timeout = nil;
end
end
@@ -245,7 +334,7 @@ function make_authenticated(event)
condition = "policy-violation",
text = "Encrypted server-to-server communication is required but was not "
..((session.direction == "outgoing" and "offered") or "used")
- });
+ }, nil, "Could not establish encrypted connection to remote server");
end
end
if hosts[host] then
@@ -255,18 +344,19 @@ function make_authenticated(event)
session.type = "s2sout";
elseif session.type == "s2sin_unauthed" then
session.type = "s2sin";
- if host then
- if not session.hosts[host] then session.hosts[host] = {}; end
- session.hosts[host].authed = true;
- end
- elseif session.type == "s2sin" and host then
+ elseif session.type ~= "s2sin" and session.type ~= "s2sout" then
+ return false;
+ end
+
+ if session.incoming and host then
if not session.hosts[host] then session.hosts[host] = {}; end
session.hosts[host].authed = true;
- else
- return false;
end
session.log("debug", "connection %s->%s is now authenticated for %s", session.from_host, session.to_host, host);
+ local local_host = session.direction == "incoming" and session.to_host or session.from_host
+ m_authn_connections:with_labels(local_host, session.direction, event.mechanism or "other"):add(1)
+
if (session.type == "s2sout" and session.external_auth ~= "succeeded") or session.type == "s2sin" then
-- Stream either used dialback for authentication or is an incoming stream.
mark_connected(session);
@@ -289,6 +379,21 @@ end
--- XMPP stream event handlers
+local function session_secure(session)
+ session.secure = true;
+ session.encrypted = true;
+
+ local sock = session.conn:socket();
+ local info = sock.info and sock:info();
+ if type(info) == "table" then
+ (session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
+ session.compressed = info.compression;
+ m_tls_params:with_labels(info.protocol, info.cipher):add(1)
+ else
+ (session.log or log)("info", "Stream encrypted");
+ end
+end
+
local stream_callbacks = { default_ns = "jabber:server" };
function stream_callbacks.handlestanza(session, stanza)
@@ -300,33 +405,25 @@ local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
function stream_callbacks.streamopened(session, attr)
-- run _streamopened in async context
- session.thread:run({ attr = attr });
+ session.thread:run({ stream = "opened", attr = attr });
end
function stream_callbacks._streamopened(session, attr)
session.version = tonumber(attr.version) or 0;
+ session.had_stream = true; -- Had a stream opened at least once
-- TODO: Rename session.secure to session.encrypted
if session.secure == false then
- session.secure = true;
- session.encrypted = true;
-
- local sock = session.conn:socket();
- if sock.info then
- local info = sock:info();
- (session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
- session.compressed = info.compression;
- else
- (session.log or log)("info", "Stream encrypted");
- session.compressed = sock.compression and sock:compression(); --COMPAT mw/luasec-hg
- end
+ session_secure(session);
end
if session.direction == "incoming" then
-- Send a reply stream header
-- Validate to/from
- local to, from = nameprep(attr.to), nameprep(attr.from);
+ local to, from = attr.to, attr.from;
+ if to then to = nameprep(attr.to); end
+ if from then from = nameprep(attr.from); end
if not to and attr.to then -- COMPAT: Some servers do not reliably set 'to' (especially on stream restarts)
session:close({ condition = "improper-addressing", text = "Invalid 'to' address" });
return;
@@ -386,15 +483,20 @@ function stream_callbacks._streamopened(session, attr)
end
session:open_stream(session.to_host, session.from_host)
+ if session.destroyed then
+ -- sending the stream opening could have failed during an opportunistic write
+ return
+ end
+
session.notopen = nil;
if session.version >= 1.0 then
local features = st.stanza("stream:features");
if to then
- hosts[to].events.fire_event("s2s-stream-features", { origin = session, features = features });
+ module:context(to):fire_event("s2s-stream-features", { origin = session, features = features });
else
(session.log or log)("warn", "No 'to' on stream header from %s means we can't offer any features", from or session.ip or "unknown host");
- fire_global_event("s2s-stream-features-legacy", { origin = session, features = features });
+ module:fire_event("s2s-stream-features-legacy", { origin = session, features = features });
end
if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then
@@ -420,24 +522,10 @@ function stream_callbacks._streamopened(session, attr)
end
end
- -- Send unauthed buffer
- -- (stanzas which are fine to send before dialback)
- -- Note that this is *not* the stanza queue (which
- -- we can only send if auth succeeds) :)
- local send_buffer = session.send_buffer;
- if send_buffer and #send_buffer > 0 then
- log("debug", "Sending s2s send_buffer now...");
- for i, data in ipairs(send_buffer) do
- session.sends2s(tostring(data));
- send_buffer[i] = nil;
- end
- end
- session.send_buffer = nil;
-
-- If server is pre-1.0, don't wait for features, just do dialback
if session.version < 1.0 then
if not session.dialback_verifying then
- hosts[session.from_host].events.fire_event("s2sout-authenticate-legacy", { origin = session });
+ module:context(session.from_host):fire_event("s2sout-authenticate-legacy", { origin = session });
else
mark_connected(session);
end
@@ -445,11 +533,48 @@ function stream_callbacks._streamopened(session, attr)
end
end
-function stream_callbacks.streamclosed(session)
+function stream_callbacks._streamclosed(session)
(session.log or log)("debug", "Received </stream:stream>");
session:close(false);
end
+function stream_callbacks.streamclosed(session, attr)
+ -- run _streamclosed in async context
+ session.thread:run({ stream = "closed", attr = attr });
+end
+
+-- Some stream conditions indicate a problem on our end, e.g. that we sent
+-- something invalid. Those should be investigated. Others are problems or
+-- events in the remote host that don't affect us, or simply that the
+-- connection was closed for being idle.
+local stream_condition_severity = {
+ ["bad-format"] = "warn";
+ ["bad-namespace-prefix"] = "warn";
+ ["conflict"] = "warn";
+ ["connection-timeout"] = "debug";
+ ["host-gone"] = "info";
+ ["host-unknown"] = "info";
+ ["improper-addressing"] = "warn";
+ ["internal-server-error"] = "warn";
+ ["invalid-from"] = "warn";
+ ["invalid-namespace"] = "warn";
+ ["invalid-xml"] = "warn";
+ ["not-authorized"] = "warn";
+ ["not-well-formed"] = "warn";
+ ["policy-violation"] = "warn";
+ ["remote-connection-failed"] = "warn";
+ ["reset"] = "info";
+ ["resource-constraint"] = "info";
+ ["restricted-xml"] = "warn";
+ ["see-other-host"] = "info";
+ ["system-shutdown"] = "info";
+ ["undefined-condition"] = "warn";
+ ["unsupported-encoding"] = "warn";
+ ["unsupported-feature"] = "warn";
+ ["unsupported-stanza-type"] = "warn";
+ ["unsupported-version"] = "warn";
+}
+
function stream_callbacks.error(session, error, data)
if error == "no-stream" then
session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}")));
@@ -470,73 +595,96 @@ function stream_callbacks.error(session, error, data)
end
end
text = condition .. (text and (" ("..text..")") or "");
- session.log("info", "Session closed by remote with error: %s", text);
+ session.log(stream_condition_severity[condition] or "info", "Session closed by remote with error: %s", text);
session:close(nil, text);
end
end
-local listener = {};
-
--- Session methods
local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'};
-local function session_close(session, reason, remote_reason)
+-- reason: stream error to send to the remote server
+-- remote_reason: stream error received from the remote server
+-- bounce_reason: stanza error to pass to bounce_sendq because stream- and stanza errors are different
+local function session_close(session, reason, remote_reason, bounce_reason)
local log = session.log or log;
- if session.conn then
- if session.notopen then
- if session.direction == "incoming" then
- session:open_stream(session.to_host, session.from_host);
- else
- session:open_stream(session.from_host, session.to_host);
- end
+ if not session.conn then
+ log("debug", "Attempt to close without associated connection with reason %q", reason);
+ return
+ end
+
+ local conn = session.conn;
+ conn:pause_writes(); -- until :close
+ if session.notopen then
+ if session.direction == "incoming" then
+ session:open_stream(session.to_host, session.from_host);
+ else
+ session:open_stream(session.from_host, session.to_host);
end
- if reason then -- nil == no err, initiated by us, false == initiated by remote
- if type(reason) == "string" then -- assume stream error
- log("debug", "Disconnecting %s[%s], <stream:error> is: %s", session.host or session.ip or "(unknown host)", session.type, reason);
- session.sends2s(st.stanza("stream:error"):tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' }));
- elseif type(reason) == "table" then
- if reason.condition then
- local stanza = st.stanza("stream:error"):tag(reason.condition, stream_xmlns_attr):up();
- if reason.text then
- stanza:tag("text", stream_xmlns_attr):text(reason.text):up();
- end
- if reason.extra then
- stanza:add_child(reason.extra);
- end
- log("debug", "Disconnecting %s[%s], <stream:error> is: %s",
- session.host or session.ip or "(unknown host)", session.type, stanza);
- session.sends2s(stanza);
- elseif reason.name then -- a stanza
- log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s",
- session.from_host or "(unknown host)", session.to_host or "(unknown host)",
- session.type, reason);
- session.sends2s(reason);
- end
+ end
+
+ local this_host = session.direction == "outgoing" and session.from_host or session.to_host
+ if not hosts[this_host] then this_host = ":unknown"; end
+
+ if reason then -- nil == no err, initiated by us, false == initiated by remote
+ local stream_error;
+ local condition, text, extra
+ if type(reason) == "string" then -- assume stream error
+ condition = reason
+ elseif type(reason) == "table" and not st.is_stanza(reason) then
+ condition = reason.condition or "undefined-condition"
+ text = reason.text
+ extra = reason.extra
+ end
+ if condition then
+ stream_error = st.stanza("stream:error"):tag(condition, stream_xmlns_attr):up();
+ if text then
+ stream_error:tag("text", stream_xmlns_attr):text(text):up();
+ end
+ if extra then
+ stream_error:add_child(extra);
end
end
-
- session.sends2s("</stream:stream>");
- function session.sends2s() return false; end
-
- -- luacheck: ignore 422/reason
- -- FIXME reason should be managed in a place common to c2s, s2s, bosh, component etc
- local reason = remote_reason or (reason and (reason.text or reason.condition)) or reason;
- session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper),
- session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed");
-
- -- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote
- local conn = session.conn;
- if reason == nil and not session.notopen and session.type == "s2sin" then
- add_task(stream_close_timeout, function ()
- if not session.destroyed then
- session.log("warn", "Failed to receive a stream close response, closing connection anyway...");
- s2s_destroy_session(session, reason);
- conn:close();
- end
- end);
- else
- s2s_destroy_session(session, reason);
- conn:close(); -- Close immediately, as this is an outgoing connection or is not authed
+ if this_host and condition then
+ m_closed_connections:with_labels(this_host, session.direction, condition):add(1)
+ end
+ if st.is_stanza(stream_error) then
+ -- to and from are never unknown on outgoing connections
+ log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s",
+ session.from_host or "(unknown host)" or session.ip, session.to_host or "(unknown host)", session.type, stream_error);
+ session.sends2s(stream_error);
end
+ else
+ m_closed_connections:with_labels(this_host or ":unknown", session.direction, reason == false and ":remote-choice" or ":local-choice"):add(1)
+ end
+
+ session.sends2s("</stream:stream>");
+ function session.sends2s() return false; end
+
+ -- luacheck: ignore 422/reason 412/reason
+ -- FIXME reason should be managed in a place common to c2s, s2s, bosh, component etc
+ local reason = remote_reason or (reason and (reason.text or reason.condition)) or reason;
+ session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper),
+ session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed");
+
+ conn:resume_writes();
+
+ if session.connect_timeout then
+ stop_timer(session.connect_timeout);
+ session.connect_timeout = nil;
+ end
+
+ -- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote
+ if reason == nil and not session.notopen and session.direction == "incoming" then
+ add_task(stream_close_timeout, function ()
+ if not session.destroyed then
+ session.log("warn", "Failed to receive a stream close response, closing connection anyway...");
+ s2s_destroy_session(session, reason, bounce_reason);
+ conn:close();
+ end
+ end);
+ else
+ s2s_destroy_session(session, reason, bounce_reason);
+ conn:close(); -- Close immediately, as this is an outgoing connection or is not authed
end
end
@@ -557,10 +705,12 @@ local function initialize_session(session)
local stream = new_xmpp_stream(session, stream_callbacks, stanza_size_limit);
session.thread = runner(function (stanza)
- if stanza.name == nil then
- stream_callbacks._streamopened(session, stanza.attr);
- else
+ if st.is_stanza(stanza) then
core_process_stanza(session, stanza);
+ elseif stanza.stream == "opened" then
+ stream_callbacks._streamopened(session, stanza.attr);
+ elseif stanza.stream == "closed" then
+ stream_callbacks._streamclosed(session, stanza.attr);
end
end, runner_callbacks, session);
@@ -581,6 +731,10 @@ local function initialize_session(session)
local conn = session.conn;
local w = conn.write;
+ if conn:ssl() then
+ session_secure(session);
+ end
+
function session.sends2s(t)
log("debug", "Sending[%s]: %s", session.type, t.top_tag and t:top_tag() or t:match("^[^>]*>?"));
if t.name then
@@ -599,8 +753,16 @@ local function initialize_session(session)
if data then
local ok, err = stream:feed(data);
if ok then return; end
- log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_"));
- session:close("not-well-formed");
+ log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
+ if err == "stanza-too-large" then
+ session:close({
+ condition = "policy-violation",
+ text = "XML stanza is too big",
+ extra = st.stanza("stanza-too-big", { xmlns = 'urn:xmpp:errors' }),
+ }, nil, "Received invalid XML from remote server");
+ else
+ session:close("not-well-formed", nil, "Received invalid XML from remote server");
+ end
end
end
@@ -613,7 +775,7 @@ local function initialize_session(session)
module:fire_event("s2s-created", { session = session });
- add_task(connect_timeout, function ()
+ session.connect_timeout = add_task(connect_timeout, function ()
if session.type == "s2sin" or session.type == "s2sout" then
return; -- Ok, we're connected
elseif session.type == "s2s_destroyed" then
@@ -648,6 +810,7 @@ function listener.onconnect(conn)
sessions[conn] = session;
session.log("debug", "Incoming s2s connection");
initialize_session(session);
+ m_accepted_tcp_connections:with_labels():add(1)
else -- Outgoing session connected
session:open_stream(session.from_host, session.to_host);
end
@@ -675,11 +838,20 @@ function listener.ondisconnect(conn, err)
local session = sessions[conn];
if session then
sessions[conn] = nil;
+ (session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed");
+ if session.secure == false and err then
+ -- TODO util.error-ify this
+ err = "Error during negotiation of encrypted connection: "..err;
+ end
+ s2s_destroy_session(session, err);
+ end
+end
+
+function listener.onfail(data, err)
+ local session = data and data.session;
+ if session then
if err and session.direction == "outgoing" and session.notopen then
(session.log or log)("debug", "s2s connection attempt failed: %s", err);
- if s2sout.attempt_connection(session, err) then
- return; -- Session lives for now
- end
end
(session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed");
s2s_destroy_session(session, err);
@@ -694,6 +866,20 @@ function listener.onreadtimeout(conn)
end
end
+function listener.ondrain(conn)
+ local session = sessions[conn];
+ if session then
+ return (hosts[session.host] or prosody).events.fire_event("s2s-ondrain", { session = session });
+ end
+end
+
+function listener.onpredrain(conn)
+ local session = sessions[conn];
+ if session then
+ return (hosts[session.host] or prosody).events.fire_event("s2s-pre-ondrain", { session = session });
+ end
+end
+
function listener.register_outgoing(conn, session)
sessions[conn] = session;
initialize_session(session);
@@ -703,6 +889,34 @@ function listener.ondetach(conn)
sessions[conn] = nil;
end
+function listener.onattach(conn, data)
+ local session = data and data.session;
+ if session then
+ session.conn = conn;
+ sessions[conn] = session;
+ initialize_session(session);
+ end
+end
+
+-- Complete the sentence "Your certificate " with what's wrong
+local function friendly_cert_error(session) --> string
+ if session.cert_chain_status == "invalid" then
+ if session.cert_chain_errors then
+ local cert_errors = set.new(session.cert_chain_errors[1]);
+ if cert_errors:contains("certificate has expired") then
+ return "has expired";
+ elseif cert_errors:contains("self signed certificate") then
+ return "is self-signed";
+ end
+ end
+ return "is not trusted"; -- for some other reason
+ elseif session.cert_identity_status == "invalid" then
+ return "is not valid for this name";
+ end
+ -- this should normally be unreachable except if no s2s auth module was loaded
+ return "could not be validated";
+end
+
function check_auth_policy(event)
local host, session = event.host, event.session;
local must_secure = secure_auth;
@@ -714,20 +928,21 @@ function check_auth_policy(event)
end
if must_secure and (session.cert_chain_status ~= "valid" or session.cert_identity_status ~= "valid") then
- module:log("warn", "Forbidding insecure connection to/from %s", host or session.ip or "(unknown host)");
- if session.direction == "incoming" then
- session:close({ condition = "not-authorized", text = "Your server's certificate is invalid, expired, or not trusted by "..session.to_host });
- else -- Close outgoing connections without warning
- session:close(false);
- end
+ local reason = friendly_cert_error(session);
+ session.log("warn", "Forbidding insecure connection to/from %s because its certificate %s", host or session.ip or "(unknown host)", reason);
+ -- XEP-0178 recommends closing outgoing connections without warning
+ -- but does not give a rationale for this.
+ -- In practice most cases are configuration mistakes or forgotten
+ -- certificate renewals. We think it's better to let the other party
+ -- know about the problem so that they can fix it.
+ session:close({ condition = "not-authorized", text = "Your server's certificate "..reason },
+ nil, "Remote server's certificate "..reason);
return false;
end
end
module:hook("s2s-check-certificate", check_auth_policy, -1);
-s2sout.set_listener(listener);
-
module:hook("server-stopping", function(event)
local reason = event.reason;
for _, session in pairs(sessions) do
@@ -742,7 +957,27 @@ module:provides("net", {
listener = listener;
default_port = 5269;
encryption = "starttls";
+ ssl_config = {
+ -- FIXME This only applies to Direct TLS, which we don't use yet.
+ -- This gets applied for real in mod_tls
+ verify = { "peer", "client_once", };
+ };
+ multiplex = {
+ protocol = "xmpp-server";
+ pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:server%1.*>";
+ };
+});
+
+
+module:provides("net", {
+ name = "s2s_direct_tls";
+ listener = listener;
+ encryption = "ssl";
+ ssl_config = {
+ verify = { "peer", "client_once", };
+ };
multiplex = {
+ protocol = "xmpp-server";
pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:server%1.*>";
};
});
diff --git a/plugins/mod_s2s/s2sout.lib.lua b/plugins/mod_s2s/s2sout.lib.lua
deleted file mode 100644
index 5f765da8..00000000
--- a/plugins/mod_s2s/s2sout.lib.lua
+++ /dev/null
@@ -1,349 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
---- Module containing all the logic for connecting to a remote server
-
--- luacheck: ignore 432/err
-
-local portmanager = require "core.portmanager";
-local wrapclient = require "net.server".wrapclient;
-local initialize_filters = require "util.filters".initialize;
-local idna_to_ascii = require "util.encodings".idna.to_ascii;
-local new_ip = require "util.ip".new_ip;
-local rfc6724_dest = require "util.rfc6724".destination;
-local socket = require "socket";
-local adns = require "net.adns";
-local t_insert, t_sort, ipairs = table.insert, table.sort, ipairs;
-local local_addresses = require "util.net".local_addresses;
-
-local s2s_destroy_session = require "core.s2smanager".destroy_session;
-
-local default_mode = module:get_option("network_default_read_size", 4096);
-
-local log = module._log;
-
-local sources = {};
-local has_ipv4, has_ipv6;
-
-local dns_timeout = module:get_option_number("dns_timeout", 15);
-local resolvers = module:get_option_set("s2s_dns_resolvers")
-
-local s2sout = {};
-
-local s2s_listener;
-
-
-function s2sout.set_listener(listener)
- s2s_listener = listener;
-end
-
-local function compare_srv_priorities(a,b)
- return a.priority < b.priority or (a.priority == b.priority and a.weight > b.weight);
-end
-
-function s2sout.initiate_connection(host_session)
- local log = host_session.log or log;
-
- initialize_filters(host_session);
- host_session.version = 1;
-
- host_session.resolver = adns.resolver();
- host_session.resolver._resolver:settimeout(dns_timeout);
- if resolvers then
- for resolver in resolvers do
- host_session.resolver._resolver:addnameserver(resolver);
- end
- end
-
- -- Kick the connection attempting machine into life
- if not s2sout.attempt_connection(host_session) then
- -- Intentionally not returning here, the
- -- session is needed, connected or not
- s2s_destroy_session(host_session);
- end
-
- if not host_session.sends2s then
- -- A sends2s which buffers data (until the stream is opened)
- -- note that data in this buffer will be sent before the stream is authed
- -- and will not be ack'd in any way, successful or otherwise
- local buffer;
- function host_session.sends2s(data)
- if not buffer then
- buffer = {};
- host_session.send_buffer = buffer;
- end
- log("debug", "Buffering data on unconnected s2sout to %s", host_session.to_host);
- buffer[#buffer+1] = data;
- log("debug", "Buffered item %d: %s", #buffer, data);
- end
- end
-end
-
-function s2sout.attempt_connection(host_session, err)
- local to_host = host_session.to_host;
- local connect_host, connect_port = to_host and idna_to_ascii(to_host), 5269;
- local log = host_session.log or log;
-
- if not connect_host then
- return false;
- end
-
- if not err then -- This is our first attempt
- log("debug", "First attempt to connect to %s, starting with SRV lookup...", to_host);
- host_session.connecting = true;
- host_session.resolver:lookup(function (answer)
- local srv_hosts = { answer = answer };
- host_session.srv_hosts = srv_hosts;
- host_session.srv_choice = 0;
- host_session.connecting = nil;
- if answer and #answer > 0 then
- log("debug", "%s has SRV records, handling...", to_host);
- for _, record in ipairs(answer) do
- t_insert(srv_hosts, record.srv);
- end
- if #srv_hosts == 1 and srv_hosts[1].target == "." then
- log("debug", "%s does not provide a XMPP service", to_host);
- s2s_destroy_session(host_session, err); -- Nothing to see here
- return;
- end
- t_sort(srv_hosts, compare_srv_priorities);
-
- local srv_choice = srv_hosts[1];
- host_session.srv_choice = 1;
- if srv_choice then
- connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port;
- log("debug", "Best record found, will connect to %s:%d", connect_host, connect_port);
- end
- else
- log("debug", "%s has no SRV records, falling back to A/AAAA", to_host);
- end
- -- Try with SRV, or just the plain hostname if no SRV
- local ok, err = s2sout.try_connect(host_session, connect_host, connect_port);
- if not ok then
- if not s2sout.attempt_connection(host_session, err) then
- -- No more attempts will be made
- s2s_destroy_session(host_session, err);
- end
- end
- end, "_xmpp-server._tcp."..connect_host..".", "SRV");
-
- return true; -- Attempt in progress
- elseif host_session.ip_hosts then
- return s2sout.try_connect(host_session, connect_host, connect_port, err);
- elseif host_session.srv_hosts and #host_session.srv_hosts > host_session.srv_choice then -- Not our first attempt, and we also have SRV
- host_session.srv_choice = host_session.srv_choice + 1;
- local srv_choice = host_session.srv_hosts[host_session.srv_choice];
- connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port;
- host_session.log("info", "Connection failed (%s). Attempt #%d: This time to %s:%d", err, host_session.srv_choice, connect_host, connect_port);
- else
- host_session.log("info", "Failed in all attempts to connect to %s", host_session.to_host);
- -- We're out of options
- return false;
- end
-
- if not (connect_host and connect_port) then
- -- Likely we couldn't resolve DNS
- log("warn", "Hmm, we're without a host (%s) and port (%s) to connect to for %s, giving up :(", connect_host, connect_port, to_host);
- return false;
- end
-
- return s2sout.try_connect(host_session, connect_host, connect_port);
-end
-
-function s2sout.try_next_ip(host_session)
- host_session.connecting = nil;
- host_session.ip_choice = host_session.ip_choice + 1;
- local ip = host_session.ip_hosts[host_session.ip_choice];
- local ok, err= s2sout.make_connect(host_session, ip.ip, ip.port);
- if not ok then
- if not s2sout.attempt_connection(host_session, err or "closed") then
- err = err and (": "..err) or "";
- s2s_destroy_session(host_session, "Connection failed"..err);
- end
- end
-end
-
-function s2sout.try_connect(host_session, connect_host, connect_port, err)
- host_session.connecting = true;
- local log = host_session.log or log;
-
- if not err then
- local IPs = {};
- host_session.ip_hosts = IPs;
- -- luacheck: ignore 231/handle4 231/handle6
- local handle4, handle6;
- local have_other_result = not(has_ipv4) or not(has_ipv6) or false;
-
- if has_ipv4 then
- handle4 = host_session.resolver:lookup(function (reply, err)
- handle4 = nil;
-
- if reply and reply[#reply] and reply[#reply].a then
- for _, ip in ipairs(reply) do
- log("debug", "DNS reply for %s gives us %s", connect_host, ip.a);
- IPs[#IPs+1] = new_ip(ip.a, "IPv4");
- end
- elseif err then
- log("debug", "Error in DNS lookup: %s", err);
- end
-
- if have_other_result then
- if #IPs > 0 then
- rfc6724_dest(host_session.ip_hosts, sources);
- for i = 1, #IPs do
- IPs[i] = {ip = IPs[i], port = connect_port};
- end
- host_session.ip_choice = 0;
- s2sout.try_next_ip(host_session);
- else
- log("debug", "DNS lookup failed to get a response for %s", connect_host);
- host_session.ip_hosts = nil;
- if not s2sout.attempt_connection(host_session, "name resolution failed") then -- Retry if we can
- log("debug", "No other records to try for %s - destroying", host_session.to_host);
- err = err and (": "..err) or "";
- s2s_destroy_session(host_session, "DNS resolution failed"..err); -- End of the line, we can't
- end
- end
- else
- have_other_result = true;
- end
- end, connect_host, "A", "IN");
- else
- have_other_result = true;
- end
-
- if has_ipv6 then
- handle6 = host_session.resolver:lookup(function (reply, err)
- handle6 = nil;
-
- if reply and reply[#reply] and reply[#reply].aaaa then
- for _, ip in ipairs(reply) do
- log("debug", "DNS reply for %s gives us %s", connect_host, ip.aaaa);
- IPs[#IPs+1] = new_ip(ip.aaaa, "IPv6");
- end
- elseif err then
- log("debug", "Error in DNS lookup: %s", err);
- end
-
- if have_other_result then
- if #IPs > 0 then
- rfc6724_dest(host_session.ip_hosts, sources);
- for i = 1, #IPs do
- IPs[i] = {ip = IPs[i], port = connect_port};
- end
- host_session.ip_choice = 0;
- s2sout.try_next_ip(host_session);
- else
- log("debug", "DNS lookup failed to get a response for %s", connect_host);
- host_session.ip_hosts = nil;
- if not s2sout.attempt_connection(host_session, "name resolution failed") then -- Retry if we can
- log("debug", "No other records to try for %s - destroying", host_session.to_host);
- err = err and (": "..err) or "";
- s2s_destroy_session(host_session, "DNS resolution failed"..err); -- End of the line, we can't
- end
- end
- else
- have_other_result = true;
- end
- end, connect_host, "AAAA", "IN");
- else
- have_other_result = true;
- end
- return true;
- elseif host_session.ip_hosts and #host_session.ip_hosts > host_session.ip_choice then -- Not our first attempt, and we also have IPs left to try
- s2sout.try_next_ip(host_session);
- else
- log("debug", "Out of IP addresses, trying next SRV record (if any)");
- host_session.ip_hosts = nil;
- if not s2sout.attempt_connection(host_session, "out of IP addresses") then -- Retry if we can
- log("debug", "No other records to try for %s - destroying", host_session.to_host);
- err = err and (": "..err) or "";
- s2s_destroy_session(host_session, "Connecting failed"..err); -- End of the line, we can't
- return false;
- end
- end
-
- return true;
-end
-
-function s2sout.make_connect(host_session, connect_host, connect_port)
- local log = host_session.log or log;
- log("debug", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port);
-
- -- Reset secure flag in case this is another
- -- connection attempt after a failed STARTTLS
- host_session.secure = nil;
- host_session.encrypted = nil;
-
- local conn, handler;
- local proto = connect_host.proto;
- if proto == "IPv4" then
- conn, handler = socket.tcp();
- elseif proto == "IPv6" and socket.tcp6 then
- conn, handler = socket.tcp6();
- else
- handler = "Unsupported protocol: "..tostring(proto);
- end
-
- if not conn then
- log("warn", "Failed to create outgoing connection, system error: %s", handler);
- return false, handler;
- end
-
- conn:settimeout(0);
- local success, err = conn:connect(connect_host.addr, connect_port);
- if not success and err ~= "timeout" then
- log("warn", "s2s connect() to %s (%s:%d) failed: %s", host_session.to_host, connect_host.addr, connect_port, err);
- return false, err;
- end
-
- conn = wrapclient(conn, connect_host.addr, connect_port, s2s_listener, default_mode);
- host_session.conn = conn;
-
- -- Register this outgoing connection so that xmppserver_listener knows about it
- -- otherwise it will assume it is a new incoming connection
- s2s_listener.register_outgoing(conn, host_session);
-
- log("debug", "Connection attempt in progress...");
- return true;
-end
-
-module:hook_global("service-added", function (event)
- if event.name ~= "s2s" then return end
-
- local s2s_sources = portmanager.get_active_services():get("s2s");
- if not s2s_sources then
- module:log("warn", "s2s not listening on any ports, outgoing connections may fail");
- return;
- end
- for source, _ in pairs(s2s_sources) do
- if source == "*" or source == "0.0.0.0" then
- for _, addr in ipairs(local_addresses("ipv4", true)) do
- sources[#sources + 1] = new_ip(addr, "IPv4");
- end
- elseif source == "::" then
- for _, addr in ipairs(local_addresses("ipv6", true)) do
- sources[#sources + 1] = new_ip(addr, "IPv6");
- end
- else
- sources[#sources + 1] = new_ip(source, (source:find(":") and "IPv6") or "IPv4");
- end
- end
- for i = 1,#sources do
- if sources[i].proto == "IPv6" then
- has_ipv6 = true;
- elseif sources[i].proto == "IPv4" then
- has_ipv4 = true;
- end
- end
- if not (has_ipv4 or has_ipv6) then
- module:log("warn", "No local IPv4 or IPv6 addresses detected, outgoing connections may fail");
- end
-end);
-
-return s2sout;
diff --git a/plugins/mod_s2s_auth_certs.lua b/plugins/mod_s2s_auth_certs.lua
index dd0eb3cb..992ee934 100644
--- a/plugins/mod_s2s_auth_certs.lua
+++ b/plugins/mod_s2s_auth_certs.lua
@@ -4,6 +4,9 @@ local cert_verify_identity = require "util.x509".verify_identity;
local NULL = {};
local log = module._log;
+local measure_cert_statuses = module:metric("counter", "checked", "", "Certificate validation results",
+ { "chain"; "identity" })
+
module:hook("s2s-check-certificate", function(event)
local session, host, cert = event.session, event.host, event.cert;
local conn = session.conn:socket();
@@ -17,9 +20,6 @@ module:hook("s2s-check-certificate", function(event)
local chain_valid, errors;
if conn.getpeerverification then
chain_valid, errors = conn:getpeerverification();
- elseif conn.getpeerchainvalid then -- COMPAT mw/luasec-hg
- chain_valid, errors = conn:getpeerchainvalid();
- errors = (not chain_valid) and { { errors } } or nil;
else
chain_valid, errors = false, { { "Chain verification not supported by this version of LuaSec" } };
end
@@ -30,6 +30,7 @@ module:hook("s2s-check-certificate", function(event)
log("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", "))
end
session.cert_chain_status = "invalid";
+ session.cert_chain_errors = errors;
else
log("debug", "certificate chain validation result: valid");
session.cert_chain_status = "valid";
@@ -45,5 +46,6 @@ module:hook("s2s-check-certificate", function(event)
log("debug", "certificate identity validation result: %s", session.cert_identity_status);
end
end
+ measure_cert_statuses:with_labels(session.cert_chain_status or "unknown", session.cert_identity_status or "unknown"):add(1);
end, 509);
diff --git a/plugins/mod_s2s_bidi.lua b/plugins/mod_s2s_bidi.lua
new file mode 100644
index 00000000..28e047de
--- /dev/null
+++ b/plugins/mod_s2s_bidi.lua
@@ -0,0 +1,40 @@
+-- Prosody IM
+-- Copyright (C) 2019 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+
+local xmlns_bidi_feature = "urn:xmpp:features:bidi"
+local xmlns_bidi = "urn:xmpp:bidi";
+
+local require_encryption = module:get_option_boolean("s2s_require_encryption", false);
+
+module:hook("s2s-stream-features", function(event)
+ local origin, features = event.origin, event.features;
+ if origin.type == "s2sin_unauthed" and (not require_encryption or origin.secure) then
+ features:tag("bidi", { xmlns = xmlns_bidi_feature }):up();
+ end
+end);
+
+module:hook_tag("http://etherx.jabber.org/streams", "features", function (session, stanza)
+ if session.type == "s2sout_unauthed" and (not require_encryption or session.secure) then
+ local bidi = stanza:get_child("bidi", xmlns_bidi_feature);
+ if bidi then
+ session.incoming = true;
+ session.log("debug", "Requesting bidirectional stream");
+ session.sends2s(st.stanza("bidi", { xmlns = xmlns_bidi }));
+ end
+ end
+end, 200);
+
+module:hook_tag("urn:xmpp:bidi", "bidi", function(session)
+ if session.type == "s2sin_unauthed" and (not require_encryption or session.secure) then
+ session.log("debug", "Requested bidirectional stream");
+ session.outgoing = true;
+ return true;
+ end
+end);
+
diff --git a/plugins/mod_saslauth.lua b/plugins/mod_saslauth.lua
index 6cd69f82..212b977a 100644
--- a/plugins/mod_saslauth.lua
+++ b/plugins/mod_saslauth.lua
@@ -12,9 +12,10 @@ local st = require "util.stanza";
local sm_bind_resource = require "core.sessionmanager".bind_resource;
local sm_make_authenticated = require "core.sessionmanager".make_authenticated;
local base64 = require "util.encodings".base64;
+local set = require "util.set";
+local errors = require "util.error";
local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler;
-local tostring = tostring;
local secure_auth_only = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", false));
local allow_unencrypted_plain_auth = module:get_option_boolean("allow_unencrypted_plain_auth", false)
@@ -51,7 +52,7 @@ local function handle_status(session, status, ret, err_msg)
module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg });
session.sasl_handler = session.sasl_handler:clean_clone();
elseif status == "success" then
- local ok, err = sm_make_authenticated(session, session.sasl_handler.username);
+ local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.scope);
if ok then
module:fire_event("authentication-success", { session = session });
session.sasl_handler = nil;
@@ -70,7 +71,6 @@ local function sasl_process_cdata(session, stanza)
local text = stanza[1];
if text then
text = base64.decode(text);
- --log("debug", "AUTH: %s", text:gsub("[%z\001-\008\011\012\014-\031]", " "));
if not text then
session.sasl_handler = nil;
session.send(build_reply("failure", "incorrect-encoding"));
@@ -80,7 +80,6 @@ local function sasl_process_cdata(session, stanza)
local status, ret, err_msg = session.sasl_handler:process(text);
status, ret, err_msg = handle_status(session, status, ret, err_msg);
local s = build_reply(status, ret, err_msg);
- log("debug", "sasl reply: %s", tostring(s));
session.send(s);
return true;
end
@@ -92,7 +91,7 @@ module:hook_tag(xmlns_sasl, "success", function (session)
session:reset_stream();
session:open_stream(session.from_host, session.to_host);
- module:fire_event("s2s-authenticated", { session = session, host = session.to_host });
+ module:fire_event("s2s-authenticated", { session = session, host = session.to_host, mechanism = "EXTERNAL" });
return true;
end)
@@ -107,18 +106,27 @@ module:hook_tag(xmlns_sasl, "failure", function (session, stanza)
break;
end
end
- if text and condition then
- condition = condition .. ": " .. text;
- end
- module:log("info", "SASL EXTERNAL with %s failed: %s", session.to_host, condition);
+ local err = errors.new({
+ -- TODO type = what?
+ text = text,
+ condition = condition,
+ }, {
+ session = session,
+ stanza = stanza,
+ });
+
+ module:log("info", "SASL EXTERNAL with %s failed: %s", session.to_host, err);
session.external_auth = "failed"
- session.external_auth_failure_reason = condition;
+ session.external_auth_failure_reason = err;
end, 500)
module:hook_tag(xmlns_sasl, "failure", function (session, stanza) -- luacheck: ignore 212/stanza
session.log("debug", "No fallback from SASL EXTERNAL failure, giving up");
- session:close(nil, session.external_auth_failure_reason);
+ session:close(nil, session.external_auth_failure_reason, errors.new({
+ type = "wait", condition = "remote-server-timeout",
+ text = "Could not authenticate to remote server",
+ }, { session = session, sasl_failure = session.external_auth_failure_reason, }));
return true;
end, 90)
@@ -184,7 +192,7 @@ local function s2s_external_auth(session, stanza)
session.external_auth = "succeeded";
session.sends2s(build_reply("success"));
module:log("info", "Accepting SASL EXTERNAL identity from %s", session.from_host);
- module:fire_event("s2s-authenticated", { session = session, host = session.from_host });
+ module:fire_event("s2s-authenticated", { session = session, host = session.from_host, mechanism = mechanism });
session:reset_stream();
return true;
end
@@ -251,7 +259,7 @@ module:hook("stream-features", function(event)
local sasl_handler = usermanager_get_sasl_handler(module.host, origin)
origin.sasl_handler = sasl_handler;
if origin.encrypted then
- -- check wether LuaSec has the nifty binding to the function needed for tls-unique
+ -- check whether LuaSec has the nifty binding to the function needed for tls-unique
-- FIXME: would be nice to have this check only once and not for every socket
if sasl_handler.add_cb_handler then
local socket = origin.conn:socket();
@@ -259,32 +267,67 @@ module:hook("stream-features", function(event)
if info.protocol == "TLSv1.3" then
log("debug", "Channel binding 'tls-unique' undefined in context of TLS 1.3");
elseif socket.getpeerfinished and socket:getpeerfinished() then
+ log("debug", "Channel binding 'tls-unique' supported");
sasl_handler:add_cb_handler("tls-unique", tls_unique);
+ else
+ log("debug", "Channel binding 'tls-unique' not supported (by LuaSec?)");
end
sasl_handler["userdata"] = {
["tls-unique"] = socket;
};
+ else
+ log("debug", "Channel binding not supported by SASL handler");
end
end
local mechanisms = st.stanza("mechanisms", mechanisms_attr);
local sasl_mechanisms = sasl_handler:mechanisms()
+ local available_mechanisms = set.new();
for mechanism in pairs(sasl_mechanisms) do
- if disabled_mechanisms:contains(mechanism) then
- log("debug", "Not offering disabled mechanism %s", mechanism);
- elseif not origin.secure and insecure_mechanisms:contains(mechanism) then
- log("debug", "Not offering mechanism %s on insecure connection", mechanism);
- else
- log("debug", "Offering mechanism %s", mechanism);
+ available_mechanisms:add(mechanism);
+ end
+ log("debug", "SASL mechanisms supported by handler: %s", available_mechanisms);
+
+ local usable_mechanisms = available_mechanisms - disabled_mechanisms;
+
+ local available_disabled = set.intersection(available_mechanisms, disabled_mechanisms);
+ if not available_disabled:empty() then
+ log("debug", "Not offering disabled mechanisms: %s", available_disabled);
+ end
+
+ local available_insecure = set.intersection(available_mechanisms, insecure_mechanisms);
+ if not origin.secure and not available_insecure:empty() then
+ log("debug", "Session is not secure, not offering insecure mechanisms: %s", available_insecure);
+ usable_mechanisms = usable_mechanisms - insecure_mechanisms;
+ end
+
+ if not usable_mechanisms:empty() then
+ log("debug", "Offering usable mechanisms: %s", usable_mechanisms);
+ for mechanism in usable_mechanisms do
mechanisms:tag("mechanism"):text(mechanism):up();
end
- end
- if mechanisms[1] then
features:add_child(mechanisms);
- elseif not next(sasl_mechanisms) then
- log("warn", "No available SASL mechanisms, verify that the configured authentication module is working");
- else
- log("warn", "All available authentication mechanisms are either disabled or not suitable for an insecure connection");
+ return;
+ end
+
+ local authmod = module:get_option_string("authentication", "internal_plain");
+ if available_mechanisms:empty() then
+ log("warn", "No available SASL mechanisms, verify that the configured authentication module '%s' is loaded and configured correctly", authmod);
+ return;
end
+
+ if not origin.secure and not available_insecure:empty() then
+ if not available_disabled:empty() then
+ log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s) or disabled (%s)",
+ authmod, available_insecure, available_disabled);
+ else
+ log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s)",
+ authmod, available_insecure);
+ end
+ elseif not available_disabled:empty() then
+ log("warn", "All SASL mechanisms provided by authentication module '%s' are disabled (%s)",
+ authmod, available_disabled);
+ end
+
else
features:tag("bind", bind_attr):tag("required"):up():up();
features:tag("session", xmpp_session_attr):tag("optional"):up():up();
diff --git a/plugins/mod_scansion_record.lua b/plugins/mod_scansion_record.lua
index 8d772b4e..5fefd398 100644
--- a/plugins/mod_scansion_record.lua
+++ b/plugins/mod_scansion_record.lua
@@ -8,7 +8,7 @@ local dt = require "util.datetime";
local dm = require "util.datamanager";
local st = require "util.stanza";
-local record_id = id.medium():lower();
+local record_id = id.short():lower();
local record_date = os.date("%Y%b%d"):lower();
local header_file = dm.getpath(record_id, "scansion", record_date, "scs", true);
local record_file = dm.getpath(record_id, "scansion", record_date, "log", true);
@@ -18,10 +18,12 @@ local scan = io.open(record_file, "w+");
local function record(string)
scan:write(string);
+ scan:flush();
end
local function record_header(string)
head:write(string);
+ head:flush();
end
local function record_object(class, name, props)
@@ -30,6 +32,7 @@ local function record_object(class, name, props)
head:write(("\t%s: %s\n"):format(k, v));
end
head:write("\n");
+ head:flush();
end
local function record_event(session, event)
@@ -37,8 +40,7 @@ local function record_event(session, event)
end
local function record_stanza(stanza, session, verb)
- local flattened = tostring(stanza):gsub("><", ">\n\t<");
- -- TODO Proper prettyprinting with indentation
+ local flattened = tostring(stanza:indent(2, "\t"));
record(session.scansion_id.." "..verb..":\n\t"..flattened.."\n\n");
end
diff --git a/plugins/mod_server_contact_info.lua b/plugins/mod_server_contact_info.lua
index 0110ceaf..42316078 100644
--- a/plugins/mod_server_contact_info.lua
+++ b/plugins/mod_server_contact_info.lua
@@ -7,6 +7,8 @@
--
local array = require "util.array";
+local jid = require "util.jid";
+local url = require "socket.url";
-- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo
local form_layout = require "util.dataforms".new({
@@ -16,6 +18,7 @@ local form_layout = require "util.dataforms".new({
{ name = "feedback", var = "feedback-addresses", type = "list-multi" },
{ name = "sales", var = "sales-addresses", type = "list-multi" },
{ name = "security", var = "security-addresses", type = "list-multi" },
+ { name = "status", var = "status-addresses", type = "list-multi" },
{ name = "support", var = "support-addresses", type = "list-multi" },
});
@@ -23,7 +26,7 @@ local form_layout = require "util.dataforms".new({
local admins = module:get_option_inherited_set("admins", {});
local contact_config = module:get_option("contact_info", {
- admin = array.collect( admins / function(admin) return "xmpp:" .. admin; end);
+ admin = array.collect(admins / jid.prep / function(admin) return url.build({scheme = "xmpp"; path = admin}); end);
});
module:add_extension(form_layout:form(contact_config, "result"));
diff --git a/plugins/mod_smacks.lua b/plugins/mod_smacks.lua
new file mode 100644
index 00000000..0215a604
--- /dev/null
+++ b/plugins/mod_smacks.lua
@@ -0,0 +1,728 @@
+-- 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 tonumber = tonumber;
+local tostring = tostring;
+local os_time = os.time;
+
+-- These metrics together allow to calculate an instantaneous
+-- "unacked stanzas" metric in the graphing frontend, without us having to
+-- iterate over all the queues.
+local tx_queued_stanzas = module:measure("tx_queued_stanzas", "counter");
+local tx_dropped_stanzas = module:metric(
+ "histogram",
+ "tx_dropped_stanzas", "", "number of stanzas in a queue which got dropped",
+ {},
+ {buckets = {0, 1, 2, 4, 8, 16, 32}}
+):with_labels();
+local tx_acked_stanzas = module:metric(
+ "histogram",
+ "tx_acked_stanzas", "", "number of items acked per ack received",
+ {},
+ {buckets = {0, 1, 2, 4, 8, 16, 32}}
+):with_labels();
+
+-- number of session resumptions attempts where the session had expired
+local resumption_expired = module:measure("session_resumption_expired", "counter");
+local resumption_age = module:metric(
+ "histogram",
+ "resumption_age", "seconds", "time the session had been hibernating at the time of a resumption",
+ {},
+ {buckets = { 0, 1, 2, 5, 10, 20, 50, 100, 200, 500 }}
+):with_labels();
+local sessions_expired = module:measure("sessions_expired", "counter");
+local sessions_started = module:measure("sessions_started", "counter");
+
+
+local datetime = require "util.datetime";
+local add_filter = require "util.filters".add_filter;
+local jid = require "util.jid";
+local smqueue = require "util.smqueue";
+local st = require "util.stanza";
+local timer = require "util.timer";
+local new_id = require "util.id".short;
+local watchdog = require "util.watchdog";
+local it = require"util.iterators";
+
+local sessionmanager = require "core.sessionmanager";
+local core_process_stanza = prosody.core_process_stanza;
+
+local xmlns_errors = "urn:ietf:params:xml:ns:xmpp-stanzas";
+local xmlns_delay = "urn:xmpp:delay";
+local xmlns_mam2 = "urn:xmpp:mam:2";
+local xmlns_sm2 = "urn:xmpp:sm:2";
+local xmlns_sm3 = "urn:xmpp:sm:3";
+
+local sm2_attr = { xmlns = xmlns_sm2 };
+local sm3_attr = { xmlns = xmlns_sm3 };
+
+local queue_size = module:get_option_number("smacks_max_queue_size", 500);
+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_old_sessions = module:get_option_number("smacks_max_old_sessions", 10);
+
+local c2s_sessions = module:shared("/*/c2s/sessions");
+local local_sessions = prosody.hosts[module.host].sessions;
+
+local function format_h(h) if h then return string.format("%d", h) end end
+
+local all_old_sessions = module:open_store("smacks_h");
+local old_session_registry = module:open_store("smacks_h", "map");
+local session_registry = module:shared "/*/smacks/resumption-tokens"; -- > user@host/resumption-token --> resource
+
+local ack_errors = require"util.error".init("mod_smacks", xmlns_sm3, {
+ head = { condition = "undefined-condition"; text = "Client acknowledged more stanzas than sent by server" };
+ tail = { condition = "undefined-condition"; text = "Client acknowledged less stanzas than already acknowledged" };
+ pop = { condition = "internal-server-error"; text = "Something went wrong with Stream Management" };
+ overflow = { condition = "resource-constraint", text = "Too many unacked stanzas remaining, session can't be resumed" }
+});
+
+-- COMPAT note the use of compatibility wrapper in events (queue:table())
+
+local function ack_delayed(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:count_unacked() or 0);
+ module:fire_event("smacks-ack-delayed", {origin = session, queue = session.outgoing_stanza_queue:table(), 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 should_ack(session, force)
+ if not session then return end -- shouldn't be possible
+ if session.destroyed then return end -- gone
+ if not session.smacks then return end -- not using
+ if session.hibernating then return end -- can't ack when asleep
+ if session.awaiting_ack then return end -- already waiting
+ if force then return force end
+ local queue = session.outgoing_stanza_queue;
+ local expected_h = session.last_acknowledged_stanza + queue:count_unacked();
+ 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 misbehaving clients report wrong
+ -- stanza counts. it is set when an <r> is really sent (e.g. inside timer), preventing any
+ -- further requests until a higher h-value would be expected.
+ return queue:count_unacked() > max_unacked and expected_h ~= session.last_requested_h;
+end
+
+local function request_ack(session, reason)
+ local queue = session.outgoing_stanza_queue;
+ session.log("debug", "Sending <r> (inside timer, before send) from %s - #queue=%d", reason, queue:count_unacked());
+ (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:count_unacked();
+ session.log("debug", "Sending <r> (inside timer, after send) from %s - #queue=%d", reason, queue:count_unacked());
+ if not session.delayed_ack_timer then
+ session.delayed_ack_timer = timer.add_task(delayed_ack_timeout, function()
+ ack_delayed(session, nil); -- we don't know if this is the only new stanza in the queue
+ end);
+ end
+end
+
+local function request_ack_now_if_needed(session, force, reason)
+ if should_ack(session, force) then
+ request_ack(session, reason);
+ 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.
+ if session.resending_unacked then return stanza end
+ if not session.smacks then return stanza end
+ local is_stanza = st.is_stanza(stanza) and
+ (not stanza.attr.xmlns or stanza.attr.xmlns == 'jabber:client')
+ and not stanza.name:find":";
+
+ if is_stanza then
+ local queue = session.outgoing_stanza_queue;
+ local cached_stanza = st.clone(stanza);
+
+ if 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:push(cached_stanza);
+ tx_queued_stanzas(1);
+
+ if session.hibernating then
+ session.log("debug", "hibernating since %s, stanza queued", datetime.datetime(session.hibernating));
+ -- FIXME queue implementation changed, anything depending on it being an array will break
+ module:fire_event("smacks-hibernation-stanza-queued", {origin = session, queue = queue:table(), stanza = cached_stanza});
+ return nil;
+ end
+ 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 = smqueue.new(queue_size);
+ session.last_acknowledged_stanza = 0;
+ end
+
+ add_filter(session, "stanzas/out", outgoing_stanza_filter, -999);
+
+ return session;
+end
+
+module:hook("pre-session-close", function(event)
+ local session = event.session;
+ if session.smacks == nil then return end
+ if session.resumption_token then
+ session.log("debug", "Revoking resumption token");
+ session_registry[jid.join(session.username, session.host, session.resumption_token)] = nil;
+ old_session_registry:set(session.username, session.resumption_token, nil);
+ session.resumption_token = nil;
+ else
+ session.log("debug", "Session not resumable");
+ end
+ if session.hibernating_watchdog then
+ session.log("debug", "Removing sleeping watchdog");
+ -- If the session is being replaced instead of resume, we don't want the
+ -- old session around to time out and cause trouble for the new session
+ session.hibernating_watchdog:cancel();
+ session.hibernating_watchdog = nil;
+ else
+ session.log("debug", "No watchdog set");
+ end
+ -- send out last ack as per revision 1.5.2 of XEP-0198
+ if session.smacks and session.conn and session.handled_stanza_count then
+ (session.sends2s or session.send)(st.stanza("a", {
+ xmlns = session.smacks;
+ h = format_h(session.handled_stanza_count);
+ }));
+ end
+end);
+
+local function wrap_session_in(session, resume)
+ if not resume then
+ sessions_started(1);
+ 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
+
+ if session.username then
+ local old_sessions, err = all_old_sessions:get(session.username);
+ module:log("debug", "Old sessions: %q", old_sessions)
+ if old_sessions then
+ local keep, count = {}, 0;
+ for token, info in it.sorted_pairs(old_sessions, function(a, b)
+ return (old_sessions[a].t or 0) > (old_sessions[b].t or 0);
+ end) do
+ count = count + 1;
+ if count > max_old_sessions then break end
+ keep[token] = info;
+ end
+ all_old_sessions:set(session.username, keep);
+ elseif err then
+ module:log("error", "Unable to retrieve old resumption counters: %s", err);
+ end
+ end
+
+ module:log("debug", "Enabling stream management");
+ session.smacks = xmlns_sm;
+
+ wrap_session(session, false);
+
+ local resume_max;
+ local resume_token;
+ local resume = stanza.attr.resume;
+ if resume == "true" or resume == "1" then
+ resume_token = new_id();
+ session_registry[jid.join(session.username, session.host, resume_token)] = session;
+ session.resumption_token = resume_token;
+ resume_max = tostring(resume_timeout);
+ end
+ (session.sends2s or session.send)(st.stanza("enabled", { xmlns = xmlns_sm, id = resume_token, resume = resume, max = resume_max }));
+ return true;
+end
+module:hook_tag(xmlns_sm2, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm2); end, 100);
+module:hook_tag(xmlns_sm3, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm3); end, 100);
+
+module:hook_tag("http://etherx.jabber.org/streams", "features",
+ function (session, stanza)
+ -- Needs to be done after flushing sendq since those aren't stored as
+ -- stanzas and counting them is weird.
+ -- TODO unify sendq and smqueue
+ timer.add_task(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) -- luacheck: ignore 212/stanza
+ module:log("debug", "Enabling stream management");
+ session.smacks = xmlns_sm;
+
+ wrap_session_in(session, false);
+
+ -- FIXME Resume?
+
+ return true;
+end
+module:hook_tag(xmlns_sm2, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm2); end, 100);
+module:hook_tag(xmlns_sm3, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm3); end, 100);
+
+function handle_r(origin, stanza, xmlns_sm) -- luacheck: ignore 212/stanza
+ 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 <a>
+ (origin.sends2s or origin.send)(st.stanza("a", { xmlns = xmlns_sm, h = format_h(origin.handled_stanza_count) }));
+ -- piggyback our own ack request if needed (see request_ack_if_needed() for explanation of last_requested_h)
+ request_ack_now_if_needed(origin, false, "piggybacked by handle_r", nil);
+ return true;
+end
+module:hook_tag(xmlns_sm2, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm2); end);
+module:hook_tag(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
+ timer.stop(origin.awaiting_ack_timer);
+ origin.awaiting_ack_timer = nil;
+ end
+ if origin.delayed_ack_timer then
+ timer.stop(origin.delayed_ack_timer)
+ origin.delayed_ack_timer = nil;
+ end
+ -- Remove handled stanzas from outgoing_stanza_queue
+ local h = tonumber(stanza.attr.h);
+ if not h then
+ origin:close{ condition = "invalid-xml"; text = "Missing or invalid 'h' attribute"; };
+ return;
+ end
+ local queue = origin.outgoing_stanza_queue;
+ local handled_stanza_count = h-queue:count_acked();
+ local acked, err = ack_errors.coerce(queue:ack(h)); -- luacheck: ignore 211/acked
+ if err then
+ origin.log("warn", "The client says it handled %d new stanzas, but we sent %d :)",
+ handled_stanza_count, queue:count_unacked());
+ origin.log("debug", "Client h: %d, our h: %d", tonumber(stanza.attr.h), queue:count_acked());
+ for i, item in queue._queue:items() do
+ origin.log("debug", "Q item %d: %s", i, item);
+ end
+ origin:close(err);
+ return;
+ end
+ tx_acked_stanzas:sample(handled_stanza_count);
+
+ origin.log("debug", "#queue = %d (acked: %d)", queue:count_unacked(), handled_stanza_count);
+ request_ack_now_if_needed(origin, false, "handle_a", nil)
+ return true;
+end
+module:hook_tag(xmlns_sm2, "a", handle_a);
+module:hook_tag(xmlns_sm3, "a", handle_a);
+
+local function handle_unacked_stanzas(session)
+ local queue = session.outgoing_stanza_queue;
+ local unacked = queue:count_unacked()
+ if unacked > 0 then
+ tx_dropped_stanzas:sample(unacked);
+ session.smacks = false; -- Disable queueing
+ session.outgoing_stanza_queue = nil;
+ for stanza in queue._queue:consume() do
+ if not module:fire_event("delivery/failure", { session = session, stanza = stanza }) then
+ if stanza.attr.type ~= "error" and stanza.attr.to ~= session.full_jid then
+ local reply = st.error_reply(stanza, "cancel", "recipient-unavailable");
+ core_process_stanza(session, reply);
+ 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)
+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 = stanza:get_child_with_attr("stanza-id", "urn:xmpp:sid:0", "by", jid.bare(session.full_jid));
+ stanza_id = stanza_id and stanza_id.attr.id;
+ 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 = local_sessions[session.username] and local_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, username = session.username, stanza = stanza });
+ session.log("debug", "mod_smacks delivery/failure 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 = event.session;
+ if not session.smacks then return end
+ if not session.resumption_token then
+ local queue = session.outgoing_stanza_queue;
+ if queue:count_unacked() > 0 then
+ session.log("debug", "Destroying session with %d unacked stanzas", queue:count_unacked());
+ handle_unacked_stanzas(session);
+ end
+ return
+ end
+ if session.hibernating then return end
+
+ session.hibernating = os_time();
+ session.hibernating_watchdog = watchdog.new(resume_timeout, function()
+ session.log("debug", "mod_smacks hibernation timeout reached...");
+ if session.destroyed then
+ session.log("debug", "The session has already been destroyed");
+ return
+ elseif not session.resumption_token then
+ -- This should normally not happen, the watchdog should be canceled from session:close()
+ session.log("debug", "The session has already been resumed or replaced");
+ return
+ end
+
+ session.log("debug", "Destroying session for hibernating too long");
+ session_registry[jid.join(session.username, session.host, session.resumption_token)] = nil;
+ old_session_registry:set(session.username, session.resumption_token,
+ { h = session.handled_stanza_count; t = os.time() });
+ session.resumption_token = nil;
+ session.resending_unacked = true; -- stop outgoing_stanza_filter from re-queueing anything anymore
+ sessionmanager.destroy_session(session, "Hibernating too long");
+ sessions_expired(1);
+ end);
+ if session.conn then
+ local conn = session.conn;
+ c2s_sessions[conn] = nil;
+ session.conn = nil;
+ conn:close();
+ end
+ module:fire_event("smacks-hibernation-start", { origin = session; queue = session.outgoing_stanza_queue:table() });
+ return true; -- Postpone destruction for now
+end);
+
+local function handle_s2s_destroyed(event)
+ local session = event.session;
+ local queue = session.outgoing_stanza_queue;
+ if queue and queue:count_unacked() > 0 then
+ session.log("warn", "Destroying session with %d unacked stanzas", queue:count_unacked());
+ if s2s_resend then
+ for stanza in queue:consume() do
+ module:send(stanza);
+ 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[jid.join(session.username, session.host, id)];
+ if not original_session then
+ local old_session = old_session_registry:get(session.username, id);
+ if old_session then
+ session.log("debug", "Tried to resume old expired session with id %s", id);
+ session.send(st.stanza("failed", { xmlns = xmlns_sm, h = format_h(old_session.h) })
+ :tag("item-not-found", { xmlns = xmlns_errors })
+ );
+ old_session_registry:set(session.username, id, nil);
+ resumption_expired(1);
+ else
+ session.log("debug", "Tried to resume non-existent session with id %s", id);
+ session.send(st.stanza("failed", { xmlns = xmlns_sm })
+ :tag("item-not-found", { xmlns = xmlns_errors })
+ );
+ end;
+ else
+ if original_session.hibernating_watchdog then
+ original_session.log("debug", "Letting the watchdog go");
+ original_session.hibernating_watchdog:cancel();
+ original_session.hibernating_watchdog = nil;
+ elseif session.hibernating then
+ original_session.log("error", "Hibernating session has no watchdog!")
+ end
+ -- zero age = was not hibernating yet
+ local age = 0;
+ if original_session.hibernating then
+ local now = os_time();
+ age = now - original_session.hibernating;
+ end
+ 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.rawsend = session.rawsend;
+ original_session.rawsend.session = original_session;
+ original_session.rawsend.conn = original_session.conn;
+ original_session.send = session.send;
+ original_session.send.session = original_session;
+ original_session.close = session.close;
+ original_session.filter = session.filter;
+ original_session.filter.session = original_session;
+ original_session.filters = session.filters;
+ original_session.send.filter = original_session.filter;
+ original_session.stream = session.stream;
+ original_session.secure = session.secure;
+ original_session.hibernating = nil;
+ original_session.resumption_counter = (original_session.resumption_counter or 0) + 1;
+ 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;
+
+ local queue = original_session.outgoing_stanza_queue;
+ local h = tonumber(stanza.attr.h);
+
+ original_session.log("debug", "Pre-resumption #queue = %d", queue:count_unacked())
+ local acked, err = ack_errors.coerce(queue:ack(h)); -- luacheck: ignore 211/acked
+
+ if not err and not queue:resumable() then
+ err = ack_errors.new("overflow");
+ end
+
+ if err or not queue:resumable() then
+ original_session.send(st.stanza("failed",
+ { xmlns = xmlns_sm; h = format_h(original_session.handled_stanza_count); previd = id }));
+ original_session:close(err);
+ return false;
+ end
+
+ original_session.send(st.stanza("resumed", { xmlns = xmlns_sm,
+ h = format_h(original_session.handled_stanza_count), previd = id }));
+
+ -- 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
+
+ session.log("debug", "resending all unacked stanzas that are still queued after resume, #queue = %d", queue:count_unacked());
+ -- FIXME Which session is it that the queue filter sees?
+ session.resending_unacked = true;
+ original_session.resending_unacked = true;
+ for _, queued_stanza in queue:resume() do
+ session.send(queued_stanza);
+ end
+ session.resending_unacked = nil;
+ original_session.resending_unacked = nil;
+ session.log("debug", "all stanzas resent, now disabling send() in this migrated session, #queue = %d", queue:count_unacked());
+ function session.send(stanza) -- luacheck: ignore 432
+ 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:table()});
+ original_session.awaiting_ack = nil; -- Don't wait for acks from before the resumption
+ request_ack_now_if_needed(original_session, true, "handle_resume", nil);
+ resumption_age:sample(age);
+ end
+ return true;
+end
+module:hook_tag(xmlns_sm2, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm2); end);
+module:hook_tag(xmlns_sm3, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm3); end);
+
+-- Events when it's sensible to request an ack
+-- Could experiment with forcing (ignoring max_unacked) <r>, but when and why?
+local request_ack_events = {
+ ["csi-client-active"] = true;
+ ["csi-flushing"] = false;
+ ["c2s-pre-ondrain"] = false;
+ ["s2s-pre-ondrain"] = false;
+};
+
+for event_name, force in pairs(request_ack_events) do
+ module:hook(event_name, function(event)
+ local session = event.session or event.origin;
+ request_ack_now_if_needed(session, force, event_name);
+ 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
+ timer.stop(session.awaiting_ack_timer);
+ session.awaiting_ack_timer = nil;
+ end
+ if session.delayed_ack_timer then
+ timer.stop(session.delayed_ack_timer);
+ session.delayed_ack_timer = nil;
+ end
+ return false; -- Kick the session
+ end
+ request_ack_now_if_needed(session, true, "read timeout");
+ return true;
+ end
+end
+
+module:hook("s2s-read-timeout", handle_read_timeout);
+module:hook("c2s-read-timeout", handle_read_timeout);
+
+module:hook_global("server-stopping", function(event)
+ if not local_sessions then
+ -- not a VirtualHost, no user sessions
+ return
+ end
+ local reason = event.reason;
+ -- Close smacks-enabled sessions ourselves instead of letting mod_c2s close
+ -- it, which invalidates the smacks session. This allows preserving the
+ -- counter value, so it can be communicated to the client when it tries to
+ -- resume the lost session after a restart.
+ for _, user in pairs(local_sessions) do
+ for _, session in pairs(user.sessions) do
+ if session.resumption_token then
+ if old_session_registry:set(session.username, session.resumption_token,
+ { h = session.handled_stanza_count; t = os.time() }) then
+ session.resumption_token = nil;
+
+ -- Deal with unacked stanzas
+ if session.outgoing_stanza_queue then
+ handle_unacked_stanzas(session);
+ end
+
+ if session.conn then
+ session.conn:close()
+ session.conn = nil;
+ -- Now when mod_c2s gets here, it will immediately destroy the
+ -- session since it is unconnected.
+ end
+
+ -- And make sure nobody tries to send anything
+ session:close{ condition = "system-shutdown", text = reason };
+ end
+ end
+ end
+ end
+end, -90);
diff --git a/plugins/mod_stanza_debug.lua b/plugins/mod_stanza_debug.lua
index 6dedb6f7..af98670c 100644
--- a/plugins/mod_stanza_debug.lua
+++ b/plugins/mod_stanza_debug.lua
@@ -1,18 +1,17 @@
module:set_global();
-local tostring = tostring;
local filters = require "util.filters";
local function log_send(t, session)
if t and t ~= "" and t ~= " " then
- session.log("debug", "SEND: %s", tostring(t));
+ session.log("debug", "SEND: %s", t);
end
return t;
end
local function log_recv(t, session)
if t and t ~= "" and t ~= " " then
- session.log("debug", "RECV: %s", tostring(t));
+ session.log("debug", "RECV: %s", t);
end
return t;
end
diff --git a/plugins/mod_storage_internal.lua b/plugins/mod_storage_internal.lua
index 0becfc8f..fa87e495 100644
--- a/plugins/mod_storage_internal.lua
+++ b/plugins/mod_storage_internal.lua
@@ -1,12 +1,18 @@
+local cache = require "util.cache";
local datamanager = require "core.storagemanager".olddm;
local array = require "util.array";
local datetime = require "util.datetime";
local st = require "util.stanza";
local now = require "util.time".now;
local id = require "util.id".medium;
+local jid_join = require "util.jid".join;
+local set = require "util.set";
local host = module.host;
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", 10000);
+local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000));
+
local driver = {};
function driver:open(store, typ)
@@ -43,6 +49,14 @@ end
local archive = {};
driver.archive = { __index = archive };
+archive.caps = {
+ total = true;
+ quota = archive_item_limit;
+ truncate = true;
+ full_id_range = true;
+ ids = true;
+};
+
function archive:append(username, key, value, when, with)
when = when or now();
if not st.is_stanza(value) then
@@ -52,30 +66,58 @@ function archive:append(username, key, value, when, with)
value.when = when;
value.with = with;
value.attr.stamp = datetime.datetime(when);
- value.attr.stamp_legacy = datetime.legacy(when);
+
+ local cache_key = jid_join(username, host, self.store);
+ local item_count = archive_item_count_cache:get(cache_key);
if key then
local items, err = datamanager.list_load(username, host, self.store);
if not items and err then return items, err; end
+
+ -- Check the quota
+ item_count = items and #items or 0;
+ archive_item_count_cache:set(cache_key, item_count);
+ if item_count >= archive_item_limit then
+ module:log("debug", "%s reached or over quota, not adding to store", username);
+ return nil, "quota-limit";
+ end
+
if items then
+ -- Filter out any item with the same key as the one being added
items = array(items);
items:filter(function (item)
return item.key ~= key;
end);
+
value.key = key;
items:push(value);
local ok, err = datamanager.list_store(username, host, self.store, items);
if not ok then return ok, err; end
+ archive_item_count_cache:set(cache_key, #items);
return key;
end
else
+ if not item_count then -- Item count not cached?
+ -- We need to load the list to get the number of items currently stored
+ local items, err = datamanager.list_load(username, host, self.store);
+ if not items and err then return items, err; end
+ item_count = items and #items or 0;
+ archive_item_count_cache:set(cache_key, item_count);
+ end
+ if item_count >= archive_item_limit then
+ module:log("debug", "%s reached or over quota, not adding to store", username);
+ return nil, "quota-limit";
+ end
key = id();
end
+ module:log("debug", "%s has %d items out of %d limit in store %s", username, item_count, archive_item_limit, self.store);
+
value.key = key;
local ok, err = datamanager.list_append(username, host, self.store, value);
if not ok then return ok, err; end
+ archive_item_count_cache:set(cache_key, item_count+1);
return key;
end
@@ -84,12 +126,18 @@ function archive:find(username, query)
if not items then
if err then
return items, err;
- else
- return function () end, 0;
+ elseif query then
+ if query.before or query.after then
+ return nil, "item-not-found";
+ end
+ if query.total then
+ return function () end, 0;
+ end
end
+ return function () end;
end
- local count = #items;
- local i = 0;
+ local count = nil;
+ local i, last_key = 0;
if query then
items = array(items);
if query.key then
@@ -97,6 +145,12 @@ function archive:find(username, query)
return item.key == query.key;
end);
end
+ if query.ids then
+ local ids = set.new(query.ids);
+ items:filter(function (item)
+ return ids:contains(item.key);
+ end);
+ end
if query.with then
items:filter(function (item)
return item.with == query.with;
@@ -114,24 +168,40 @@ function archive:find(username, query)
return when <= query["end"];
end);
end
- count = #items;
+ if query.total then
+ count = #items;
+ end
if query.reverse then
items:reverse();
if query.before then
- for j = 1, count do
+ local found = false;
+ for j = 1, #items do
if (items[j].key or tostring(j)) == query.before then
+ found = true;
i = j;
break;
end
end
+ if not found then
+ return nil, "item-not-found";
+ end
end
+ last_key = query.after;
elseif query.after then
- for j = 1, count do
+ local found = false;
+ for j = 1, #items do
if (items[j].key or tostring(j)) == query.after then
+ found = true;
i = j;
break;
end
end
+ if not found then
+ return nil, "item-not-found";
+ end
+ last_key = query.before;
+ elseif query.before then
+ last_key = query.before;
end
if query.limit and #items - i > query.limit then
items[i+query.limit+1] = nil;
@@ -140,26 +210,97 @@ function archive:find(username, query)
return function ()
i = i + 1;
local item = items[i];
- if not item then return; end
+ if not item or (last_key and item.key == last_key) then
+ return;
+ end
local key = item.key or tostring(i);
local when = item.when or datetime.parse(item.attr.stamp);
local with = item.with;
item.key, item.when, item.with = nil, nil, nil;
item.attr.stamp = nil;
+ -- COMPAT Stored data may still contain legacy XEP-0091 timestamp
item.attr.stamp_legacy = nil;
item = st.deserialize(item);
return key, item, when, with;
end, count;
end
+function archive:get(username, wanted_key)
+ local iter, err = self:find(username, { key = wanted_key })
+ if not iter then return iter, err; end
+ for key, stanza, when, with in iter do
+ if key == wanted_key then
+ return stanza, when, with;
+ end
+ end
+ return nil, "item-not-found";
+end
+
+function archive:set(username, key, new_value, new_when, new_with)
+ local items, err = datamanager.list_load(username, host, self.store);
+ if not items then
+ if err then
+ return items, err;
+ else
+ return nil, "item-not-found";
+ end
+ end
+
+ for i = 1, #items do
+ local old_item = items[i];
+ if old_item.key == key then
+ local item = st.preserialize(st.clone(new_value));
+
+ local when = new_when or old_item.when or datetime.parse(old_item.attr.stamp);
+ item.key = key;
+ item.when = when;
+ item.with = new_with or old_item.with;
+ item.attr.stamp = datetime.datetime(when);
+ items[i] = item;
+ return datamanager.list_store(username, host, self.store, items);
+ end
+ end
+
+ return nil, "item-not-found";
+end
+
function archive:dates(username)
local items, err = datamanager.list_load(username, host, self.store);
if not items then return items, err; end
return array(items):pluck("when"):map(datetime.date):unique();
end
+function archive:summary(username, query)
+ local iter, err = self:find(username, query)
+ if not iter then return iter, err; end
+ local counts = {};
+ local earliest = {};
+ local latest = {};
+ local body = {};
+ for _, stanza, when, with in iter do
+ counts[with] = (counts[with] or 0) + 1;
+ if earliest[with] == nil then
+ earliest[with] = when;
+ end
+ latest[with] = when;
+ body[with] = stanza:get_child_text("body") or body[with];
+ end
+ return {
+ counts = counts;
+ earliest = earliest;
+ latest = latest;
+ body = body;
+ };
+end
+
+function archive:users()
+ return datamanager.users(host, self.store, "list");
+end
+
function archive:delete(username, query)
+ local cache_key = jid_join(username, host, self.store);
if not query or next(query) == nil then
+ archive_item_count_cache:set(cache_key, nil);
return datamanager.list_store(username, host, self.store, nil);
end
local items, err = datamanager.list_load(username, host, self.store);
@@ -167,6 +308,7 @@ function archive:delete(username, query)
if err then
return items, err;
end
+ archive_item_count_cache:set(cache_key, 0);
-- Store is empty
return 0;
end
@@ -216,6 +358,7 @@ function archive:delete(username, query)
end
local ok, err = datamanager.list_store(username, host, self.store, items);
if not ok then return ok, err; end
+ archive_item_count_cache:set(cache_key, #items);
return count;
end
diff --git a/plugins/mod_storage_memory.lua b/plugins/mod_storage_memory.lua
index 745e394b..9b0024ab 100644
--- a/plugins/mod_storage_memory.lua
+++ b/plugins/mod_storage_memory.lua
@@ -4,10 +4,13 @@ local envload = require "util.envload".envload;
local st = require "util.stanza";
local is_stanza = st.is_stanza or function (s) return getmetatable(s) == st.stanza_mt end
local new_id = require "util.id".medium;
+local set = require "util.set";
local auto_purge_enabled = module:get_option_boolean("storage_memory_temporary", false);
local auto_purge_stores = module:get_option_set("storage_memory_temporary_stores", {});
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", 1000);
+
local memory = setmetatable({}, {
__index = function(t, k)
local store = module:shared(k)
@@ -51,6 +54,14 @@ archive_store.__index = archive_store;
archive_store.users = _users;
+archive_store.caps = {
+ total = true;
+ quota = archive_item_limit;
+ truncate = true;
+ full_id_range = true;
+ ids = true;
+};
+
function archive_store:append(username, key, value, when, with)
if is_stanza(value) then
value = st.preserialize(value);
@@ -70,6 +81,8 @@ function archive_store:append(username, key, value, when, with)
end
if a[key] then
table.remove(a, a[key]);
+ elseif #a >= archive_item_limit then
+ return nil, "quota-limit";
end
local i = #a+1;
a[i] = v;
@@ -80,10 +93,18 @@ end
function archive_store:find(username, query)
local items = self.store[username or NULL];
if not items then
- return function () end, 0;
+ if query then
+ if query.before or query.after then
+ return nil, "item-not-found";
+ end
+ if query.total then
+ return function () end, 0;
+ end
+ end
+ return function () end;
end
- local count = #items;
- local i = 0;
+ local count = nil;
+ local i, last_key = 0;
if query then
items = array():append(items);
if query.key then
@@ -91,6 +112,12 @@ function archive_store:find(username, query)
return item.key == query.key;
end);
end
+ if query.ids then
+ local ids = set.new(query.ids);
+ items:filter(function (item)
+ return ids:contains(item.key);
+ end);
+ end
if query.with then
items:filter(function (item)
return item.with == query.with;
@@ -106,24 +133,40 @@ function archive_store:find(username, query)
return item.when <= query["end"];
end);
end
- count = #items;
+ if query.total then
+ count = #items;
+ end
if query.reverse then
items:reverse();
if query.before then
- for j = 1, count do
+ local found = false;
+ for j = 1, #items do
if (items[j].key or tostring(j)) == query.before then
+ found = true;
i = j;
break;
end
end
+ if not found then
+ return nil, "item-not-found";
+ end
end
+ last_key = query.after;
elseif query.after then
- for j = 1, count do
+ local found = false;
+ for j = 1, #items do
if (items[j].key or tostring(j)) == query.after then
+ found = true;
i = j;
break;
end
end
+ if not found then
+ return nil, "item-not-found";
+ end
+ last_key = query.before;
+ elseif query.before then
+ last_key = query.before;
end
if query.limit and #items - i > query.limit then
items[i+query.limit+1] = nil;
@@ -132,11 +175,62 @@ function archive_store:find(username, query)
return function ()
i = i + 1;
local item = items[i];
- if not item then return; end
+ if not item or (last_key and item.key == last_key) then return; end
return item.key, item.value(), item.when, item.with;
end, count;
end
+function archive_store:get(username, wanted_key)
+ local items = self.store[username or NULL];
+ if not items then return nil, "item-not-found"; end
+ local i = items[wanted_key];
+ if not i then return nil, "item-not-found"; end
+ local item = items[i];
+ return item.value(), item.when, item.with;
+end
+
+function archive_store:set(username, wanted_key, new_value, new_when, new_with)
+ local items = self.store[username or NULL];
+ if not items then return nil, "item-not-found"; end
+ local i = items[wanted_key];
+ if not i then return nil, "item-not-found"; end
+ local item = items[i];
+
+ if is_stanza(new_value) then
+ new_value = st.preserialize(new_value);
+ item.value = envload("return xml"..serialize(new_value), "=(stanza)", { xml = st.deserialize })
+ else
+ item.value = envload("return "..serialize(new_value), "=(data)", {});
+ end
+ if new_when then
+ item.when = new_when;
+ end
+ if new_with then
+ item.with = new_when;
+ end
+ return true;
+end
+
+function archive_store:summary(username, query)
+ local iter, err = self:find(username, query)
+ if not iter then return iter, err; end
+ local counts = {};
+ local earliest = {};
+ local latest = {};
+ for _, _, when, with in iter do
+ counts[with] = (counts[with] or 0) + 1;
+ if earliest[with] == nil then
+ earliest[with] = when;
+ end
+ latest[with] = when;
+ end
+ return {
+ counts = counts;
+ earliest = earliest;
+ latest = latest;
+ };
+end
+
function archive_store:delete(username, query)
if not query or next(query) == nil then
diff --git a/plugins/mod_storage_sql.lua b/plugins/mod_storage_sql.lua
index 5b1c3603..3bfe1739 100644
--- a/plugins/mod_storage_sql.lua
+++ b/plugins/mod_storage_sql.lua
@@ -1,17 +1,19 @@
-- luacheck: ignore 212/self
+local cache = require "util.cache";
local json = require "util.json";
local sql = require "util.sql";
local xml_parse = require "util.xml".parse;
local uuid = require "util.uuid";
local resolve_relative_path = require "util.paths".resolve_relative_path;
+local jid_join = require "util.jid".join;
local is_stanza = require"util.stanza".is_stanza;
local t_concat = table.concat;
local noop = function() end
-local unpack = table.unpack or unpack;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
local function iterator(result)
return function(result_)
local row = result_();
@@ -148,7 +150,13 @@ end
--- Archive store API
--- luacheck: ignore 512 431/user 431/store
+local archive_item_limit = module:get_option_number("storage_archive_item_limit");
+local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000));
+
+local item_count_cache_hit = module:measure("item_count_cache_hit", "rate");
+local item_count_cache_miss = module:measure("item_count_cache_miss", "rate")
+
+-- luacheck: ignore 512 431/user 431/store 431/err
local map_store = {};
map_store.__index = map_store;
map_store.remove = {};
@@ -225,13 +233,94 @@ function map_store:set_keys(username, keydatas)
return result;
end
+function map_store:get_all(key)
+ if type(key) ~= "string" or key == "" then
+ return nil, "get_all only supports non-empty string keys";
+ end
+ local ok, result = engine:transaction(function()
+ local query = [[
+ SELECT "user", "type", "value"
+ FROM "prosody"
+ WHERE "host"=? AND "store"=? AND "key"=?
+ ]];
+
+ local data;
+ for row in engine:select(query, host, self.store, key) do
+ local key_data, err = deserialize(row[2], row[3]);
+ assert(key_data ~= nil, err);
+ if data == nil then
+ data = {};
+ end
+ data[row[1]] = key_data;
+ end
+
+ return data;
+
+ end);
+ if not ok then return nil, result; end
+ return result;
+end
+
+function map_store:delete_all(key)
+ if type(key) ~= "string" or key == "" then
+ return nil, "delete_all only supports non-empty string keys";
+ end
+ local ok, result = engine:transaction(function()
+ local delete_sql = [[
+ DELETE FROM "prosody"
+ WHERE "host"=? AND "store"=? AND "key"=?;
+ ]];
+ engine:delete(delete_sql, host, self.store, key);
+ return true;
+ end);
+ if not ok then return nil, result; end
+ return result;
+end
+
local archive_store = {}
archive_store.caps = {
total = true;
+ quota = archive_item_limit;
+ truncate = true;
+ full_id_range = true;
+ ids = true;
+ wildcard_delete = true;
};
archive_store.__index = archive_store
function archive_store:append(username, key, value, when, with)
local user,store = username,self.store;
+ local cache_key = jid_join(username, host, store);
+ local item_count = archive_item_count_cache:get(cache_key);
+ if not item_count then
+ item_count_cache_miss();
+ local ok, ret = engine:transaction(function()
+ local count_sql = [[
+ SELECT COUNT(*) FROM "prosodyarchive"
+ WHERE "host"=? AND "user"=? AND "store"=?;
+ ]];
+ local result = engine:select(count_sql, host, user, store);
+ if result then
+ for row in result do
+ item_count = row[1];
+ end
+ end
+ end);
+ if not ok or not item_count then
+ module:log("error", "Failed while checking quota for %s: %s", username, ret);
+ return nil, "Failure while checking quota";
+ end
+ archive_item_count_cache:set(cache_key, item_count);
+ else
+ item_count_cache_hit();
+ end
+
+ if archive_item_limit then
+ module:log("debug", "%s has %d items out of %d limit", username, item_count, archive_item_limit);
+ if item_count >= archive_item_limit then
+ return nil, "quota-limit";
+ end
+ end
+
when = when or os.time();
with = with or "";
local ok, ret = engine:transaction(function()
@@ -245,12 +334,16 @@ function archive_store:append(username, key, value, when, with)
VALUES (?,?,?,?,?,?,?,?);
]];
if key then
- engine:delete(delete_sql, host, user or "", store, key);
+ local result = engine:delete(delete_sql, host, user or "", store, key);
+ if result then
+ item_count = item_count - result:affected();
+ end
else
key = uuid.generate();
end
local t, encoded_value = assert(serialize(value));
engine:insert(insert_sql, host, user or "", store, when, with, key, t, encoded_value);
+ archive_item_count_cache:set(cache_key, item_count+1);
return key;
end);
if not ok then return ok, ret; end
@@ -285,47 +378,65 @@ local function archive_where(query, args, where)
where[#where+1] = "\"key\" = ?";
args[#args+1] = query.key
end
+
+ -- Set of ids
+ if query.ids then
+ local nids, nargs = #query.ids, #args;
+ -- COMPAT Lua 5.1: No separator argument to string.rep
+ where[#where + 1] = "\"key\" IN (" .. string.rep("?,", nids):sub(1,-2) .. ")";
+ for i, id in ipairs(query.ids) do
+ args[nargs+i] = id;
+ end
+ end
end
local function archive_where_id_range(query, args, where)
- local args_len = #args
-- Before or after specific item, exclusive
+ local id_lookup_sql = [[
+ SELECT "sort_id"
+ FROM "prosodyarchive"
+ WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ?
+ LIMIT 1;
+ ]];
if query.after then -- keys better be unique!
- where[#where+1] = [[
- "sort_id" > COALESCE(
- (
- SELECT "sort_id"
- FROM "prosodyarchive"
- WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ?
- LIMIT 1
- ), 0)
- ]];
- args[args_len+1], args[args_len+2], args[args_len+3], args[args_len+4] = query.after, args[1], args[2], args[3];
- args_len = args_len + 4
+ local after_id = nil;
+ for row in engine:select(id_lookup_sql, query.after, args[1], args[2], args[3]) do
+ after_id = row[1];
+ end
+ if not after_id then
+ return nil, "item-not-found";
+ end
+ where[#where+1] = '"sort_id" > ?';
+ args[#args+1] = after_id;
end
if query.before then
- where[#where+1] = [[
- "sort_id" < COALESCE(
- (
- SELECT "sort_id"
- FROM "prosodyarchive"
- WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ?
- LIMIT 1
- ),
- (
- SELECT MAX("sort_id")+1
- FROM "prosodyarchive"
- )
- )
- ]]
- args[args_len+1], args[args_len+2], args[args_len+3], args[args_len+4] = query.before, args[1], args[2], args[3];
+ local before_id = nil;
+ for row in engine:select(id_lookup_sql, query.before, args[1], args[2], args[3]) do
+ before_id = row[1];
+ end
+ if not before_id then
+ return nil, "item-not-found";
+ end
+ where[#where+1] = '"sort_id" < ?';
+ args[#args+1] = before_id;
end
+ return true;
end
function archive_store:find(username, query)
query = query or {};
local user,store = username,self.store;
- local total;
- local ok, result = engine:transaction(function()
+ local cache_key = jid_join(username, host, self.store);
+ local total = archive_item_count_cache:get(cache_key);
+ (total and item_count_cache_hit or item_count_cache_miss)();
+ if query.start == nil and query.with == nil and query["end"] == nil and query.key == nil and query.ids == nil then
+ -- the query is for the whole archive, so a cached 'total' should be a
+ -- relatively accurate response if that's all that is requested
+ if total ~= nil and query.limit == 0 then return noop, total; end
+ else
+ -- not usable, so refresh it later if needed
+ total = nil;
+ end
+ local ok, result, err = engine:transaction(function()
local sql_query = [[
SELECT "key", "type", "value", "when", "with"
FROM "prosodyarchive"
@@ -338,7 +449,8 @@ function archive_store:find(username, query)
archive_where(query, args, where);
-- Total matching
- if query.total then
+ if query.total and not total then
+
local stats = engine:select("SELECT COUNT(*) FROM \"prosodyarchive\" WHERE "
.. t_concat(where, " AND "), unpack(args));
if stats then
@@ -346,12 +458,16 @@ function archive_store:find(username, query)
total = row[1];
end
end
+ if query.start == nil and query.with == nil and query["end"] == nil and query.key == nil and query.ids == nil then
+ archive_item_count_cache:set(cache_key, total);
+ end
if query.limit == 0 then -- Skip the real query
return noop, total;
end
end
- archive_where_id_range(query, args, where);
+ local ok, err = archive_where_id_range(query, args, where);
+ if not ok then return ok, err; end
if query.limit then
args[#args+1] = query.limit;
@@ -361,7 +477,8 @@ function archive_store:find(username, query)
and "DESC" or "ASC", query.limit and " LIMIT ?" or "");
return engine:select(sql_query, unpack(args));
end);
- if not ok then return ok, result end
+ if not ok then return ok, result; end
+ if not result then return nil, err; end
return function()
local row = result();
if row ~= nil then
@@ -372,6 +489,95 @@ function archive_store:find(username, query)
end, total;
end
+function archive_store:get(username, key)
+ local iter, err = self:find(username, { key = key })
+ if not iter then return iter, err; end
+ for _, stanza, when, with in iter do
+ return stanza, when, with;
+ end
+ return nil, "item-not-found";
+end
+
+function archive_store:set(username, key, new_value, new_when, new_with)
+ local user,store = username,self.store;
+ local ok, result = engine:transaction(function ()
+
+ local update_query = [[
+ UPDATE "prosodyarchive"
+ SET %s
+ WHERE %s
+ ]];
+ local args = { host, user or "", store, key };
+ local setf = {};
+ local where = { "\"host\" = ?", "\"user\" = ?", "\"store\" = ?", "\"key\" = ?"};
+
+ if new_value then
+ table.insert(setf, '"type" = ?')
+ table.insert(setf, '"value" = ?')
+ local t, value = serialize(new_value);
+ table.insert(args, 1, t);
+ table.insert(args, 2, value);
+ end
+
+ if new_when then
+ table.insert(setf, 1, '"when" = ?')
+ table.insert(args, 1, new_when);
+ end
+
+ if new_with then
+ table.insert(setf, 1, '"with" = ?')
+ table.insert(args, 1, new_with);
+ end
+
+ update_query = update_query:format(t_concat(setf, ", "), t_concat(where, " AND "));
+ return engine:update(update_query, unpack(args));
+ end);
+ if not ok then return ok, result; end
+ return result:affected() == 1;
+end
+
+function archive_store:summary(username, query)
+ query = query or {};
+ local user,store = username,self.store;
+ local ok, result = engine:transaction(function()
+ local sql_query = [[
+ SELECT DISTINCT "with", COUNT(*), MIN("when"), MAX("when")
+ FROM "prosodyarchive"
+ WHERE %s
+ GROUP BY "with"
+ ORDER BY "sort_id" %s%s;
+ ]];
+ local args = { host, user or "", store, };
+ local where = { "\"host\" = ?", "\"user\" = ?", "\"store\" = ?", };
+
+ archive_where(query, args, where);
+
+ archive_where_id_range(query, args, where);
+
+ if query.limit then
+ args[#args+1] = query.limit;
+ end
+
+ sql_query = sql_query:format(t_concat(where, " AND "), query.reverse
+ and "DESC" or "ASC", query.limit and " LIMIT ?" or "");
+ return engine:select(sql_query, unpack(args));
+ end);
+ if not ok then return ok, result end
+ local counts = {};
+ local earliest, latest = {}, {};
+ for row in result do
+ local with, count = row[1], row[2];
+ counts[with] = count;
+ earliest[with] = row[3];
+ latest[with] = row[4];
+ end
+ return {
+ counts = counts;
+ earliest = earliest;
+ latest = latest;
+ };
+end
+
function archive_store:delete(username, query)
query = query or {};
local user,store = username,self.store;
@@ -384,7 +590,8 @@ function archive_store:delete(username, query)
table.remove(where, 2);
end
archive_where(query, args, where);
- archive_where_id_range(query, args, where);
+ local ok, err = archive_where_id_range(query, args, where);
+ if not ok then return ok, err; end
if query.truncate == nil then
sql_query = sql_query:format(t_concat(where, " AND "));
else
@@ -423,9 +630,28 @@ function archive_store:delete(username, query)
end
return engine:delete(sql_query, unpack(args));
end);
+ if username == true then
+ archive_item_count_cache:clear();
+ else
+ local cache_key = jid_join(username, host, self.store);
+ archive_item_count_cache:set(cache_key, nil);
+ end
return ok and stmt:affected(), stmt;
end
+function archive_store:users()
+ local ok, result = engine:transaction(function()
+ local select_sql = [[
+ SELECT DISTINCT "user"
+ FROM "prosodyarchive"
+ WHERE "host"=? AND "store"=?;
+ ]];
+ return engine:select(select_sql, host, self.store);
+ end);
+ if not ok then error(result); end
+ return iterator(result);
+end
+
local stores = {
keyval = keyval_store;
map = map_store;
@@ -610,9 +836,10 @@ function module.load()
if prosody.prosodyctl then return; end
local engines = module:shared("/*/sql/connections");
local params = normalize_params(module:get_option("sql", default_params));
- engine = engines[sql.db2uri(params)];
+ local db_uri = sql.db2uri(params);
+ engine = engines[db_uri];
if not engine then
- module:log("debug", "Creating new engine");
+ module:log("debug", "Creating new engine %s", db_uri);
engine = sql:create_engine(params, function (engine) -- luacheck: ignore 431/engine
if module:get_option("sql_manage_tables", true) then
-- Automatically create table, ignore failure (table probably already exists)
@@ -640,7 +867,7 @@ end
function module.command(arg)
local config = require "core.configmanager";
- local prosodyctl = require "util.prosodyctl";
+ local hi = require "util.human.io";
local command = table.remove(arg, 1);
if command == "upgrade" then
-- We need to find every unique dburi in the config
@@ -655,7 +882,7 @@ function module.command(arg)
end
print("");
print("Ensure you have working backups of the above databases before continuing! ");
- if not prosodyctl.show_yesno("Continue with the database upgrade? [yN]") then
+ if not hi.show_yesno("Continue with the database upgrade? [yN]") then
print("Ok, no upgrade. But you do have backups, don't you? ...don't you?? :-)");
return;
end
diff --git a/plugins/mod_storage_xep0227.lua b/plugins/mod_storage_xep0227.lua
index 229ad6b5..1a7baaeb 100644
--- a/plugins/mod_storage_xep0227.lua
+++ b/plugins/mod_storage_xep0227.lua
@@ -2,26 +2,40 @@
local ipairs, pairs = ipairs, pairs;
local setmetatable = setmetatable;
local tostring = tostring;
-local next = next;
-local t_remove = table.remove;
+local next, unpack = next, table.unpack or unpack; --luacheck: ignore 113/unpack
local os_remove = os.remove;
local io_open = io.open;
+local jid_bare = require "util.jid".bare;
+local jid_prep = require "util.jid".prep;
+local jid_join = require "util.jid".join;
+local array = require "util.array";
+local base64 = require "util.encodings".base64;
+local dt = require "util.datetime";
+local hex = require "util.hex";
+local it = require "util.iterators";
local paths = require"util.paths";
+local set = require "util.set";
local st = require "util.stanza";
local parse_xml_real = require "util.xml".parse;
-local function getXml(user, host)
- local jid = user.."@"..host;
+local lfs = require "lfs";
+
+local function default_get_user_xml(self, user, host) --luacheck: ignore 212/self
+ local jid = jid_join(user, host);
local path = paths.join(prosody.paths.data, jid..".xml");
- local f = io_open(path);
- if not f then return; end
+ local f, err = io_open(path);
+ if not f then
+ module:log("debug", "Unable to load XML file for <%s>: %s", jid, err);
+ return;
+ end
+ module:log("debug", "Loaded %s", path);
local s = f:read("*a");
f:close();
return parse_xml_real(s);
end
-local function setXml(user, host, xml)
- local jid = user.."@"..host;
+local function default_set_user_xml(self, user, host, xml) --luacheck: ignore 212/self
+ local jid = jid_join(user, host);
local path = paths.join(prosody.paths.data, jid..".xml");
local f, err = io_open(path, "w");
if not f then return f, err; end
@@ -45,34 +59,45 @@ local function getUserElement(xml)
end
end
end
+ module:log("warn", "Unable to find user element");
end
local function createOuterXml(user, host)
return st.stanza("server-data", {xmlns='urn:xmpp:pie:0'})
:tag("host", {jid=host})
:tag("user", {name = user});
end
-local function removeFromArray(array, value)
- for i,item in ipairs(array) do
- if item == value then
- t_remove(array, i);
- return;
- end
- end
+
+local function hex_to_base64(s)
+ return base64.encode(hex.from(s));
end
-local function removeStanzaChild(s, child)
- removeFromArray(s.tags, child);
- removeFromArray(s, child);
+
+local function base64_to_hex(s)
+ return base64.encode(hex.from(s));
end
local handlers = {};
--- In order to support mod_auth_internal_hashed
+-- In order to support custom account properties
local extended = "http://prosody.im/protocol/extended-xep0227\1";
+local scram_hash_name = module:get_option_string("password_hash", "SHA-1");
+local scram_properties = set.new({ "server_key", "stored_key", "iteration_count", "salt" });
+
handlers.accounts = {
get = function(self, user)
- user = getUserElement(getXml(user, self.host));
- if user and user.attr.password then
+ user = getUserElement(self:_get_user_xml(user, self.host));
+ local scram_credentials = user and user:get_child_with_attr(
+ "scram-credentials", "urn:xmpp:pie:0#scram",
+ "mechanism", "SCRAM-"..scram_hash_name
+ );
+ if scram_credentials then
+ return {
+ iteration_count = tonumber(scram_credentials:get_child_text("iter-count"));
+ server_key = base64_to_hex(scram_credentials:get_child_text("server-key"));
+ stored_key = base64_to_hex(scram_credentials:get_child_text("stored-key"));
+ salt = base64.decode(scram_credentials:get_child_text("salt"));
+ };
+ elseif user and user.attr.password then
return { password = user.attr.password };
elseif user then
local data = {};
@@ -85,26 +110,44 @@ handlers.accounts = {
end
end;
set = function(self, user, data)
- if data then
- local xml = getXml(user, self.host);
- if not xml then xml = createOuterXml(user, self.host); end
- local usere = getUserElement(xml);
- for k, v in pairs(data) do
- if k == "password" then
- usere.attr.password = v;
- else
- usere.attr[extended..k] = v;
- end
- end
- return setXml(user, self.host, xml);
- else
- return setXml(user, self.host, nil);
+ if not data then
+ return self:_set_user_xml(user, self.host, nil);
end
+
+ local xml = self:_get_user_xml(user, self.host);
+ if not xml then xml = createOuterXml(user, self.host); end
+ local usere = getUserElement(xml);
+
+ local account_properties = set.new(it.to_array(it.keys(data)));
+
+ -- Include SCRAM credentials if known
+ if account_properties:contains_set(scram_properties) then
+ local scram_el = st.stanza("scram-credentials", { xmlns = "urn:xmpp:pie:0#scram", mechanism = "SCRAM-"..scram_hash_name })
+ :text_tag("server-key", hex_to_base64(data.server_key))
+ :text_tag("stored-key", hex_to_base64(data.stored_key))
+ :text_tag("iter-count", ("%d"):format(data.iteration_count))
+ :text_tag("salt", base64.encode(data.salt));
+ usere:add_child(scram_el);
+ account_properties:exclude(scram_properties);
+ end
+
+ -- Include the password if present
+ if account_properties:contains("password") then
+ usere.attr.password = data.password;
+ account_properties:remove("password");
+ end
+
+ -- Preserve remaining properties as namespaced attributes
+ for property in account_properties do
+ usere.attr[extended..property] = data[property];
+ end
+
+ return self:_set_user_xml(user, self.host, xml);
end;
};
handlers.vcard = {
get = function(self, user)
- user = getUserElement(getXml(user, self.host));
+ user = getUserElement(self:_get_user_xml(user, self.host));
if user then
local vcard = user:get_child("vCard", 'vcard-temp');
if vcard then
@@ -113,27 +156,24 @@ handlers.vcard = {
end
end;
set = function(self, user, data)
- local xml = getXml(user, self.host);
+ local xml = self:_get_user_xml(user, self.host);
local usere = xml and getUserElement(xml);
if usere then
- local vcard = usere:get_child("vCard", 'vcard-temp');
- if vcard then
- removeStanzaChild(usere, vcard);
- elseif not data then
+ usere:remove_children("vCard", "vcard-temp");
+ if not data or not data.attr then
+ -- No data to set, old one deleted, success
return true;
end
- if data then
- vcard = st.deserialize(data);
- usere:add_child(vcard);
- end
- return setXml(user, self.host, xml);
+ local vcard = st.deserialize(data);
+ usere:add_child(vcard);
+ return self:_set_user_xml(user, self.host, xml);
end
return true;
end;
};
handlers.private = {
get = function(self, user)
- user = getUserElement(getXml(user, self.host));
+ user = getUserElement(self:_get_user_xml(user, self.host));
if user then
local private = user:get_child("query", "jabber:iq:private");
if private then
@@ -146,19 +186,18 @@ handlers.private = {
end
end;
set = function(self, user, data)
- local xml = getXml(user, self.host);
+ local xml = self:_get_user_xml(user, self.host);
local usere = xml and getUserElement(xml);
if usere then
- local private = usere:get_child("query", 'jabber:iq:private');
- if private then removeStanzaChild(usere, private); end
+ usere:remove_children("query", "jabber:iq:private");
if data and next(data) ~= nil then
- private = st.stanza("query", {xmlns='jabber:iq:private'});
+ local private = st.stanza("query", {xmlns='jabber:iq:private'});
for _,tag in pairs(data) do
private:add_child(st.deserialize(tag));
end
usere:add_child(private);
end
- return setXml(user, self.host, xml);
+ return self:_set_user_xml(user, self.host, xml);
end
return true;
end;
@@ -166,7 +205,7 @@ handlers.private = {
handlers.roster = {
get = function(self, user)
- user = getUserElement(getXml(user, self.host));
+ user = getUserElement(self:_get_user_xml(user, self.host));
if user then
local roster = user:get_child("query", "jabber:iq:roster");
if roster then
@@ -196,11 +235,11 @@ handlers.roster = {
end
end;
set = function(self, user, data)
- local xml = getXml(user, self.host);
+ local xml = self:_get_user_xml(user, self.host);
local usere = xml and getUserElement(xml);
if usere then
- local roster = usere:get_child("query", 'jabber:iq:roster');
- if roster then removeStanzaChild(usere, roster); end
+ local user_jid = jid_join(usere.name, self.host);
+ usere:remove_children("query", "jabber:iq:roster");
usere:maptags(function (tag)
if tag.attr.xmlns == "jabber:client" and tag.name == "presence" and tag.attr.type == "subscribe" then
return nil;
@@ -208,20 +247,23 @@ handlers.roster = {
return tag;
end);
if data and next(data) ~= nil then
- roster = st.stanza("query", {xmlns='jabber:iq:roster'});
+ local roster = st.stanza("query", {xmlns='jabber:iq:roster'});
usere:add_child(roster);
- for jid, item in pairs(data) do
- if jid then
- roster:tag("item", {
- jid = jid,
- subscription = item.subscription,
- ask = item.ask,
- name = item.name,
- });
- for group in pairs(item.groups) do
- roster:tag("group"):text(group):up();
+ for contact_jid, item in pairs(data) do
+ if contact_jid ~= false then
+ contact_jid = jid_bare(jid_prep(contact_jid));
+ if contact_jid ~= user_jid then -- Skip self-contacts
+ roster:tag("item", {
+ jid = contact_jid,
+ subscription = item.subscription,
+ ask = item.ask,
+ name = item.name,
+ });
+ for group in pairs(item.groups) do
+ roster:tag("group"):text(group):up();
+ end
+ roster:up(); -- move out from item
end
- roster:up(); -- move out from item
else
roster.attr.version = item.version;
for pending_jid in pairs(item.pending) do
@@ -230,23 +272,481 @@ handlers.roster = {
end
end
end
- return setXml(user, self.host, xml);
+ return self:_set_user_xml(user, self.host, xml);
end
return true;
end;
};
+-- PEP node configuration/etc. (not items)
+local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner";
+local lib_pubsub = module:require "pubsub";
+handlers.pep = {
+ get = function (self, user)
+ local xml = self:_get_user_xml(user, self.host);
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return nil;
+ end
+ local nodes = {
+ --[[
+ [node_name] = {
+ name = node_name;
+ config = {};
+ affiliations = {};
+ subscribers = {};
+ };
+ ]]
+ };
+ local owner_el = user_el:get_child("pubsub", xmlns_pubsub_owner);
+ for node_el in owner_el:childtags() do
+ local node_name = node_el.attr.node;
+ local node = nodes[node_name];
+ if not node then
+ node = {
+ name = node_name;
+ config = {};
+ affiliations = {};
+ subscribers = {};
+ };
+ nodes[node_name] = node;
+ end
+ if node_el.name == "configure" then
+ local form = node_el:get_child("x", "jabber:x:data");
+ if form then
+ node.config = lib_pubsub.node_config_form:data(form);
+ end
+ elseif node_el.name == "affiliations" then
+ for affiliation_el in node_el:childtags("affiliation") do
+ local aff_jid = jid_prep(affiliation_el.attr.jid);
+ local aff_value = affiliation_el.attr.affiliation;
+ if aff_jid and aff_value then
+ node.affiliations[aff_jid] = aff_value;
+ end
+ end
+ elseif node_el.name == "subscriptions" then
+ for subscription_el in node_el:childtags("subscription") do
+ local sub_jid = jid_prep(subscription_el.attr.jid);
+ local sub_state = subscription_el.attr.subscription;
+ if sub_jid and sub_state == "subscribed" then
+ local options;
+ local subscription_options_el = subscription_el:get_child("options");
+ if subscription_options_el then
+ local options_form = subscription_options_el:get_child("x", "jabber:x:data");
+ if options_form then
+ options = lib_pubsub.subscription_options_form:data(options_form);
+ end
+ end
+ node.subscribers[sub_jid] = options or true;
+ end
+ end
+ else
+ module:log("warn", "Ignoring unknown pubsub element: %s", node_el.name);
+ end
+ end
+ return nodes;
+ end;
+ set = function(self, user, data)
+ local xml = self:_get_user_xml(user, self.host);
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return true;
+ end
+ -- Remove existing data, if any
+ user_el:remove_children("pubsub", xmlns_pubsub_owner);
+
+ -- Generate new data
+ local owner_el = st.stanza("pubsub", { xmlns = xmlns_pubsub_owner });
+
+ for node_name, node_data in pairs(data) do
+ if node_data == true then
+ node_data = { config = {} };
+ end
+ local configure_el = st.stanza("configure", { node = node_name })
+ :add_child(lib_pubsub.node_config_form:form(node_data.config, "submit"));
+ owner_el:add_child(configure_el);
+ if node_data.affiliations and next(node_data.affiliations) ~= nil then
+ local affiliations_el = st.stanza("affiliations", { node = node_name });
+ for aff_jid, aff_value in pairs(node_data.affiliations) do
+ affiliations_el:tag("affiliation", { jid = aff_jid, affiliation = aff_value }):up();
+ end
+ owner_el:add_child(affiliations_el);
+ end
+ if node_data.subscribers and next(node_data.subscribers) ~= nil then
+ local subscriptions_el = st.stanza("subscriptions", { node = node_name });
+ for sub_jid, sub_data in pairs(node_data.subscribers) do
+ local sub_el = st.stanza("subscription", { jid = sub_jid, subscribed = "subscribed" });
+ if sub_data ~= true then
+ local options_form = lib_pubsub.subscription_options_form:form(sub_data, "submit");
+ sub_el:tag("options"):add_child(options_form):up();
+ end
+ subscriptions_el:add_child(sub_el);
+ end
+ owner_el:add_child(subscriptions_el);
+ end
+ end
+
+ user_el:add_child(owner_el);
+
+ return self:_set_user_xml(user, self.host, xml);
+ end;
+};
+
+-- PEP items
+local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
+handlers.pep_ = {
+ _stores = function (self, xml) --luacheck: ignore 212/self
+ local store_names = set.new();
+
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return store_names;
+ end
+
+ -- Locate existing pubsub element, if any
+ local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+ if not pubsub_el then
+ return store_names;
+ end
+
+ -- Find node items element, if any
+ for items_el in pubsub_el:childtags("items") do
+ store_names:add("pep_"..items_el.attr.node);
+ end
+ return store_names;
+ end;
+ find = function (self, user, query)
+ -- query keys: limit, reverse, key (id)
+
+ local xml = self:_get_user_xml(user, self.host);
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return nil, "no 227 user element found";
+ end
+
+ local node_name = self.datastore:match("^pep_(.+)$");
+
+ -- Locate existing pubsub element, if any
+ local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+ if not pubsub_el then
+ return nil;
+ end
+
+ -- Find node items element, if any
+ local node_items_el;
+ for items_el in pubsub_el:childtags("items") do
+ if items_el.attr.node == node_name then
+ node_items_el = items_el;
+ break;
+ end
+ end
+
+ if not node_items_el then
+ return nil;
+ end
+
+ local user_jid = user.."@"..self.host;
+
+ local results = {};
+ for item_el in node_items_el:childtags("item") do
+ if query and query.key then
+ if item_el.attr.id == query.key then
+ table.insert(results, { item_el.attr.id, item_el.tags[1], 0, user_jid });
+ break;
+ end
+ else
+ table.insert(results, { item_el.attr.id, item_el.tags[1], 0, user_jid });
+ end
+ if query and query.limit and #results >= query.limit then
+ break;
+ end
+ end
+ if query and query.reverse then
+ return array.reverse(results);
+ end
+ local i = 0;
+ return function ()
+ i = i + 1;
+ local v = results[i];
+ if v == nil then return nil; end
+ return unpack(v, 1, 4);
+ end;
+ end;
+ append = function (self, user, key, payload, when, with) --luacheck: ignore 212/when 212/with 212/key
+ local xml = self:_get_user_xml(user, self.host);
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return true;
+ end
+
+ local node_name = self.datastore:match("^pep_(.+)$");
+
+ -- Locate existing pubsub element, if any
+ local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+ if not pubsub_el then
+ pubsub_el = st.stanza("pubsub", { xmlns = xmlns_pubsub });
+ user_el:add_child(pubsub_el);
+ end
+
+ -- Find node items element, if any
+ local node_items_el;
+ for items_el in pubsub_el:childtags("items") do
+ if items_el.attr.node == node_name then
+ node_items_el = items_el;
+ break;
+ end
+ end
+
+ if not node_items_el then
+ -- Doesn't exist yet, create one
+ node_items_el = st.stanza("items", { node = node_name });
+ pubsub_el:add_child(node_items_el);
+ end
+
+ -- Append item to pubsub_el
+ local item_el = st.stanza("item", { id = key })
+ :add_child(payload);
+ node_items_el:add_child(item_el);
+
+ return self:_set_user_xml(user, self.host, xml);
+ end;
+ delete = function (self, user, query)
+ -- query keys: limit, reverse, key (id)
+
+ local xml = self:_get_user_xml(user, self.host);
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return nil, "no 227 user element found";
+ end
+
+ local node_name = self.datastore:match("^pep_(.+)$");
+
+ -- Locate existing pubsub element, if any
+ local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+ if not pubsub_el then
+ return nil;
+ end
+
+ -- Find node items element, if any
+ local node_items_el;
+ for items_el in pubsub_el:childtags("items") do
+ if items_el.attr.node == node_name then
+ node_items_el = items_el;
+ break;
+ end
+ end
+
+ if not node_items_el then
+ return nil;
+ end
+
+ local results = array();
+ for item_el in pubsub_el:childtags("item") do
+ if query and query.key then
+ if item_el.attr.id == query.key then
+ table.insert(results, item_el);
+ break;
+ end
+ else
+ table.insert(results, item_el);
+ end
+ if query and query.limit and #results >= query.limit then
+ break;
+ end
+ end
+ if query and query.truncate then
+ results:sub(-query.truncate);
+ end
+
+ -- Actually remove the matching items
+ local delete_keys = set.new(results:map(function (item) return item.attr.id; end));
+ pubsub_el:maptags(function (item_el)
+ if delete_keys:contains(item_el.attr.id) then
+ return nil;
+ end
+ return item_el;
+ end);
+ return self:_set_user_xml(user, self.host, xml);
+ end;
+};
+
+-- MAM archives
+local xmlns_pie_mam = "urn:xmpp:pie:0#mam";
+handlers.archive = {
+ find = function (self, user, query)
+ assert(query == nil, "XEP-0313 queries are not supported on XEP-0227 files");
+
+ local xml = self:_get_user_xml(user, self.host);
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return nil, "no 227 user element found";
+ end
+
+ -- Locate existing archive element, if any
+ local archive_el = user_el:get_child("archive", xmlns_pie_mam);
+ if not archive_el then
+ return nil;
+ end
+
+ local user_jid = user.."@"..self.host;
+
+
+ local f, s, result_el = archive_el:childtags("result", "urn:xmpp:mam:2");
+ return function ()
+ result_el = f(s, result_el);
+ if not result_el then return nil; end
+
+ local id = result_el.attr.id;
+ local item = result_el:find("{urn:xmpp:forward:0}forwarded/{jabber:client}message");
+ assert(item, "Invalid stanza in XEP-0227 archive");
+ local when = dt.parse(result_el:find("{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay@stamp"));
+ local to_bare, from_bare = jid_bare(item.attr.to), jid_bare(item.attr.from);
+ local with = to_bare == user_jid and from_bare or to_bare;
+ -- id, item, when, with
+ return id, item, when, with;
+ end;
+ end;
+ append = function (self, user, key, payload, when, with) --luacheck: ignore 212/when 212/with 212/key
+ local xml = self:_get_user_xml(user, self.host);
+ local user_el = xml and getUserElement(xml);
+ if not user_el then
+ return true;
+ end
+
+ -- Locate existing archive element, if any
+ local archive_el = user_el:get_child("archive", xmlns_pie_mam);
+ if not archive_el then
+ archive_el = st.stanza("archive", { xmlns = xmlns_pie_mam });
+ user_el:add_child(archive_el);
+ end
+
+ local item = st.clone(payload);
+ item.attr.xmlns = "jabber:client";
+
+ local result_el = st.stanza("result", { xmlns = "urn:xmpp:mam:2", id = key })
+ :tag("forwarded", { xmlns = "urn:xmpp:forward:0" })
+ :tag("delay", { xmlns = "urn:xmpp:delay", stamp = dt.datetime(when) }):up()
+ :add_child(item)
+ :up();
+
+ -- Append item to archive_el
+ archive_el:add_child(result_el);
+
+ return self:_set_user_xml(user, self.host, xml);
+ end;
+};
-----------------------------
local driver = {};
+local function users(self)
+ local file_patt = "^.*@"..(self.host:gsub("%p", "%%%1")).."%.xml$";
+
+ local f, s, filename = lfs.dir(prosody.paths.data);
+
+ return function ()
+ filename = f(s, filename);
+ while filename and not filename:match(file_patt) do
+ filename = f(s, filename);
+ end
+ if not filename then return nil; end
+ return filename:match("^[^@]+");
+ end;
+end
+
function driver:open(datastore, typ) -- luacheck: ignore 212/self
- if typ and typ ~= "keyval" then return nil, "unsupported-store"; end
+ if typ and typ ~= "keyval" and typ ~= "archive" then return nil, "unsupported-store"; end
local handler = handlers[datastore];
+ if not handler and datastore:match("^pep_") then
+ handler = handlers.pep_;
+ end
if not handler then return nil, "unsupported-datastore"; end
- local instance = setmetatable({ host = module.host; datastore = datastore; }, { __index = handler });
+ local instance = setmetatable({
+ host = module.host;
+ datastore = datastore;
+ users = users;
+ _get_user_xml = assert(default_get_user_xml);
+ _set_user_xml = default_set_user_xml;
+ }, {
+ __index = handler;
+ }
+ );
if instance.init then instance:init(); end
return instance;
end
+-- Custom API that allows some configuration
+function driver:open_xep0227(datastore, typ, options)
+ local instance, err = self:open(datastore, typ);
+ if not instance then
+ return instance, err;
+ end
+ if options then
+ instance._set_user_xml = assert(options.set_user_xml);
+ instance._get_user_xml = assert(options.get_user_xml);
+ end
+ return instance;
+end
+
+local function get_store_names_from_xml(self, user_xml)
+ local stores = set.new();
+ for handler_name, handler_funcs in pairs(handlers) do
+ if handler_funcs._stores then
+ stores:include(handler_funcs._stores(self, user_xml));
+ else
+ stores:add(handler_name);
+ end
+ end
+ return stores;
+end
+
+local function get_store_names(self, path)
+ local stores = set.new();
+ local f, err = io_open(paths.join(prosody.paths.data, path));
+ if not f then
+ module:log("warn", "Unable to load XML file for <%s>: %s", "store listing", err);
+ return stores;
+ end
+ module:log("info", "Loaded %s", path);
+ local s = f:read("*a");
+ f:close();
+ local user_xml = parse_xml_real(s);
+ return get_store_names_from_xml(self, user_xml);
+end
+
+function driver:stores(username)
+ local store_dir = prosody.paths.data;
+
+ local mode, err = lfs.attributes(store_dir, "mode");
+ if not mode then
+ return function() module:log("debug", "Could not iterate over stores in %s: %s", store_dir, err); end
+ end
+
+ local file_patt = "^.*@"..(module.host:gsub("%p", "%%%1")).."%.xml$";
+
+ local all_users = username == true;
+
+ local store_names = set.new();
+
+ for filename in lfs.dir(prosody.paths.data) do
+ if filename:match(file_patt) then
+ if all_users or filename == username.."@"..module.host..".xml" then
+ store_names:include(get_store_names(self, filename));
+ if not all_users then break; end
+ end
+ end
+ end
+
+ return store_names:items();
+end
+
+function driver:xep0227_user_stores(username, host)
+ local user_xml = self:_get_user_xml(username, host);
+ if not user_xml then
+ return nil;
+ end
+ local store_names = get_store_names_from_xml(username, host);
+ return store_names:items();
+end
+
module:provides("storage", driver);
diff --git a/plugins/mod_tls.lua b/plugins/mod_tls.lua
index 8c0c4e20..a97f7027 100644
--- a/plugins/mod_tls.lua
+++ b/plugins/mod_tls.lua
@@ -35,9 +35,10 @@ local host = hosts[module.host];
local ssl_ctx_c2s, ssl_ctx_s2sout, ssl_ctx_s2sin;
local ssl_cfg_c2s, ssl_cfg_s2sout, ssl_cfg_s2sin;
+local err_c2s, err_s2sin, err_s2sout;
function module.load(reload)
- local NULL, err = {};
+ local NULL = {};
local modhost = module.host;
local parent = modhost:match("%.(.*)$");
@@ -53,16 +54,20 @@ function module.load(reload)
local host_s2s = rawgetopt(modhost, "s2s_ssl") or parent_s2s;
module:log("debug", "Creating context for c2s");
- ssl_ctx_c2s, err, ssl_cfg_c2s = create_context(host.host, "server", host_c2s, host_ssl, global_c2s); -- for incoming client connections
- if not ssl_ctx_c2s then module:log("error", "Error creating context for c2s: %s", err); end
+ local request_client_certs = { verify = { "peer", "client_once", }; };
+
+ ssl_ctx_c2s, err_c2s, ssl_cfg_c2s = create_context(host.host, "server", host_c2s, host_ssl, global_c2s); -- for incoming client connections
+ if not ssl_ctx_c2s then module:log("error", "Error creating context for c2s: %s", err_c2s); end
module:log("debug", "Creating context for s2sout");
- ssl_ctx_s2sout, err, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s); -- for outgoing server connections
- if not ssl_ctx_s2sout then module:log("error", "Error creating contexts for s2sout: %s", err); end
+ -- for outgoing server connections
+ ssl_ctx_s2sout, err_s2sout, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s, request_client_certs);
+ if not ssl_ctx_s2sout then module:log("error", "Error creating contexts for s2sout: %s", err_s2sout); end
module:log("debug", "Creating context for s2sin");
- ssl_ctx_s2sin, err, ssl_cfg_s2sin = create_context(host.host, "server", host_s2s, host_ssl, global_s2s); -- for incoming server connections
- if not ssl_ctx_s2sin then module:log("error", "Error creating contexts for s2sin: %s", err); end
+ -- for incoming server connections
+ ssl_ctx_s2sin, err_s2sin, ssl_cfg_s2sin = create_context(host.host, "server", host_s2s, host_ssl, global_s2s, request_client_certs);
+ if not ssl_ctx_s2sin then module:log("error", "Error creating contexts for s2sin: %s", err_s2sin); end
if reload then
module:log("info", "Certificates reloaded");
@@ -83,12 +88,21 @@ local function can_do_tls(session)
return session.ssl_ctx;
end
if session.type == "c2s_unauthed" then
+ if not ssl_ctx_c2s and c2s_require_encryption then
+ session.log("error", "No TLS context available for c2s. Earlier error was: %s", err_c2s);
+ end
session.ssl_ctx = ssl_ctx_c2s;
session.ssl_cfg = ssl_cfg_c2s;
elseif session.type == "s2sin_unauthed" and allow_s2s_tls then
+ if not ssl_ctx_s2sin and s2s_require_encryption then
+ session.log("error", "No TLS context available for s2sin. Earlier error was: %s", err_s2sin);
+ end
session.ssl_ctx = ssl_ctx_s2sin;
session.ssl_cfg = ssl_cfg_s2sin;
elseif session.direction == "outgoing" and allow_s2s_tls then
+ if not ssl_ctx_s2sout and s2s_require_encryption then
+ session.log("error", "No TLS context available for s2sout. Earlier error was: %s", err_s2sout);
+ end
session.ssl_ctx = ssl_ctx_s2sout;
session.ssl_cfg = ssl_cfg_s2sout;
else
@@ -107,6 +121,7 @@ module:hook("stanza/urn:ietf:params:xml:ns:xmpp-tls:starttls", function(event)
local origin = event.origin;
if can_do_tls(origin) then
(origin.sends2s or origin.send)(starttls_proceed);
+ if origin.destroyed then return end
origin:reset_stream();
origin.conn:starttls(origin.ssl_ctx);
origin.log("debug", "TLS negotiation started for %s...", origin.type);
@@ -119,7 +134,7 @@ module:hook("stanza/urn:ietf:params:xml:ns:xmpp-tls:starttls", function(event)
return true;
end);
--- Advertize stream feature
+-- Advertise stream feature
module:hook("stream-features", function(event)
local origin, features = event.origin, event.features;
if can_do_tls(origin) then
@@ -136,13 +151,28 @@ end);
-- For s2sout connections, start TLS if we can
module:hook_tag("http://etherx.jabber.org/streams", "features", function (session, stanza)
module:log("debug", "Received features element");
- if can_do_tls(session) and stanza:get_child("starttls", xmlns_starttls) then
- module:log("debug", "%s is offering TLS, taking up the offer...", session.to_host);
+ if can_do_tls(session) then
+ if stanza:get_child("starttls", xmlns_starttls) then
+ module:log("debug", "%s is offering TLS, taking up the offer...", session.to_host);
+ elseif s2s_require_encryption then
+ module:log("debug", "%s is *not* offering TLS, trying anyways!", session.to_host);
+ else
+ module:log("debug", "%s is not offering TLS", session.to_host);
+ return;
+ end
session.sends2s(starttls_initiate);
return true;
end
end, 500);
+module:hook("s2sout-authenticate-legacy", function(event)
+ local session = event.origin;
+ if s2s_require_encryption and can_do_tls(session) then
+ session.sends2s(starttls_initiate);
+ return true;
+ end
+end, 200);
+
module:hook_tag(xmlns_starttls, "proceed", function (session, stanza) -- luacheck: ignore 212/stanza
if session.type == "s2sout_unauthed" and can_do_tls(session) then
module:log("debug", "Proceeding with TLS on s2sout...");
@@ -152,3 +182,9 @@ module:hook_tag(xmlns_starttls, "proceed", function (session, stanza) -- luachec
return true;
end
end);
+
+module:hook_tag(xmlns_starttls, "failure", function (session, stanza) -- luacheck: ignore 212/stanza
+ module:log("warn", "TLS negotiation with %s failed.", session.to_host);
+ session:close(nil, "TLS negotiation failed");
+ return false;
+end);
diff --git a/plugins/mod_tokenauth.lua b/plugins/mod_tokenauth.lua
new file mode 100644
index 00000000..c04a1aa4
--- /dev/null
+++ b/plugins/mod_tokenauth.lua
@@ -0,0 +1,82 @@
+local id = require "util.id";
+local jid = require "util.jid";
+local base64 = require "util.encodings".base64;
+
+local token_store = module:open_store("auth_tokens", "map");
+
+function create_jid_token(actor_jid, token_jid, token_scope, token_ttl)
+ token_jid = jid.prep(token_jid);
+ if not actor_jid or token_jid ~= actor_jid and not jid.compare(token_jid, actor_jid) then
+ return nil, "not-authorized";
+ end
+
+ local token_username, token_host, token_resource = jid.split(token_jid);
+
+ if token_host ~= module.host then
+ return nil, "invalid-host";
+ end
+
+ local token_info = {
+ owner = actor_jid;
+ created = os.time();
+ expires = token_ttl and (os.time() + token_ttl) or nil;
+ jid = token_jid;
+ session = {
+ username = token_username;
+ host = token_host;
+ resource = token_resource;
+
+ auth_scope = token_scope;
+ };
+ };
+
+ local token_id = id.long();
+ local token = base64.encode("1;"..jid.join(token_username, token_host)..";"..token_id);
+ token_store:set(token_username, token_id, token_info);
+
+ return token, token_info;
+end
+
+local function parse_token(encoded_token)
+ local token = base64.decode(encoded_token);
+ if not token then return nil; end
+ local token_jid, token_id = token:match("^1;([^;]+);(.+)$");
+ if not token_jid then return nil; end
+ local token_user, token_host = jid.split(token_jid);
+ return token_id, token_user, token_host;
+end
+
+function get_token_info(token)
+ local token_id, token_user, token_host = parse_token(token);
+ if not token_id then
+ return nil, "invalid-token-format";
+ end
+ if token_host ~= module.host then
+ return nil, "invalid-host";
+ end
+
+ local token_info, err = token_store:get(token_user, token_id);
+ if not token_info then
+ if err then
+ return nil, "internal-error";
+ end
+ return nil, "not-authorized";
+ end
+
+ if token_info.expires and token_info.expires < os.time() then
+ return nil, "not-authorized";
+ end
+
+ return token_info
+end
+
+function revoke_token(token)
+ local token_id, token_user, token_host = parse_token(token);
+ if not token_id then
+ return nil, "invalid-token-format";
+ end
+ if token_host ~= module.host then
+ return nil, "invalid-host";
+ end
+ return token_store:set(token_user, token_id, nil);
+end
diff --git a/plugins/mod_tombstones.lua b/plugins/mod_tombstones.lua
new file mode 100644
index 00000000..4d3ce3ab
--- /dev/null
+++ b/plugins/mod_tombstones.lua
@@ -0,0 +1,79 @@
+-- TODO warn when trying to create an user before the tombstone expires
+-- e.g. via telnet or other admin interface
+local datetime = require "util.datetime";
+local errors = require "util.error";
+local jid_split = require"util.jid".split;
+local st = require "util.stanza";
+
+-- Using a map store as key-value store so that removal of all user data
+-- does not also remove the tombstone, which would defeat the point
+local graveyard = module:open_store(nil, "map");
+
+local ttl = module:get_option_number("user_tombstone_expiry", nil);
+-- Keep tombstones forever by default
+--
+-- Rationale:
+-- There is no way to be completely sure when remote services have
+-- forgotten and revoked all memberships.
+
+-- TODO If the user left a JID they moved to, return a gone+redirect error
+-- TODO Attempt to deregister from MUCs based on bookmarks
+-- TODO Unsubscribe from pubsub services if a notification is received
+
+module:hook_global("user-deleted", function(event)
+ if event.host == module.host then
+ local ok, err = graveyard:set(nil, event.username, os.time());
+ if not ok then module:log("error", "Could store tombstone for %s: %s", event.username, err); end
+ end
+end);
+
+-- Public API
+function has_tombstone(username)
+ local tombstone, err = graveyard:get(nil, username);
+
+ if err or not tombstone then return tombstone, err; end
+
+ if ttl and tombstone + ttl < os.time() then
+ module:log("debug", "Tombstone for %s created at %s has expired", username, datetime.datetime(tombstone));
+ graveyard:set(nil, username, nil);
+ return nil;
+ end
+ return tombstone;
+end
+
+module:hook("user-registering", function(event)
+ local tombstone, err = has_tombstone(event.username);
+
+ if err then
+ event.allowed, event.error = errors.coerce(false, err);
+ return true;
+ elseif not tombstone then
+ -- Feel free
+ return;
+ end
+
+ module:log("debug", "Tombstone for %s created at %s", event.username, datetime.datetime(tombstone));
+ event.allowed = false;
+ return true;
+end);
+
+module:hook("presence/bare", function(event)
+ local origin, presence = event.origin, event.stanza;
+
+ -- We want to undo any left-over presence subscriptions and notify the former
+ -- contact that they're gone.
+ --
+ -- FIXME This leaks that the user once existed. Hard to avoid without keeping
+ -- the contact list in some form, which we don't want to do for privacy
+ -- reasons. Bloom filter perhaps?
+ if has_tombstone(jid_split(presence.attr.to)) then
+ if presence.attr.type == "probe" then
+ origin.send(st.error_reply(presence, "cancel", "gone", "User deleted"));
+ origin.send(st.presence({ type = "unsubscribed"; to = presence.attr.from; from = presence.attr.to }));
+ elseif presence.attr.type == nil or presence.attr.type == "unavailable" then
+ origin.send(st.error_reply(presence, "cancel", "gone", "User deleted"));
+ origin.send(st.presence({ type = "unsubscribe"; to = presence.attr.from; from = presence.attr.to }));
+ end
+ return true;
+ end
+end, 1);
diff --git a/plugins/mod_turn_external.lua b/plugins/mod_turn_external.lua
new file mode 100644
index 00000000..63b36175
--- /dev/null
+++ b/plugins/mod_turn_external.lua
@@ -0,0 +1,28 @@
+local secret = module:get_option_string("turn_external_secret");
+local host = module:get_option_string("turn_external_host", module.host);
+local user = module:get_option_string("turn_external_user");
+local port = module:get_option_number("turn_external_port", 3478);
+local ttl = module:get_option_number("turn_external_ttl", 86400);
+
+local services = module:get_option_set("turn_external_services", {"stun-udp"; "turn-udp"});
+
+if not secret then error("mod_" .. module.name .. " requires that 'turn_external_secret' be set") end
+
+module:depends "external_services";
+
+for _, type in ipairs({"stun"; "turn"}) do
+ for _, transport in ipairs({"udp"; "tcp"}) do
+ if services:contains(type .. "-" .. transport) then
+ module:add_item("external_service", {
+ type = type;
+ transport = transport;
+ host = host;
+ port = port;
+
+ username = type == "turn" and user or nil;
+ secret = type == "turn" and secret or nil;
+ ttl = type == "turn" and ttl or nil;
+ })
+ end
+ end
+end
diff --git a/plugins/mod_uptime.lua b/plugins/mod_uptime.lua
index ccd8e511..8a01fb17 100644
--- a/plugins/mod_uptime.lua
+++ b/plugins/mod_uptime.lua
@@ -16,7 +16,7 @@ module:add_feature("jabber:iq:last");
module:hook("iq-get/host/jabber:iq:last:query", function(event)
local origin, stanza = event.origin, event.stanza;
- origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))}));
+ origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(("%d"):format(os.difftime(os.time(), start_time)))}));
return true;
end);
@@ -42,6 +42,6 @@ function uptime_command_handler ()
return { info = uptime_text(), status = "completed" };
end
-local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler);
+local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler, "any");
module:provides("adhoc", descriptor);
diff --git a/plugins/mod_user_account_management.lua b/plugins/mod_user_account_management.lua
index 615c1ed6..130ed089 100644
--- a/plugins/mod_user_account_management.lua
+++ b/plugins/mod_user_account_management.lua
@@ -53,9 +53,10 @@ local function handle_registration_stanza(event)
log("info", "User removed their account: %s@%s", username, host);
module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session });
else
- local username = nodeprep(query:get_child_text("username"));
+ local username = query:get_child_text("username");
local password = query:get_child_text("password");
if username and password then
+ username = nodeprep(username);
if username == session.username then
if usermanager_set_password(username, password, session.host, session.resource) then
session.send(st.reply(stanza));
diff --git a/plugins/mod_vcard.lua b/plugins/mod_vcard.lua
index b1a4c6e8..c3d6fb8b 100644
--- a/plugins/mod_vcard.lua
+++ b/plugins/mod_vcard.lua
@@ -19,7 +19,7 @@ local function handle_vcard(event)
if stanza.attr.type == "get" then
local vCard;
if to then
- local node, host = jid_split(to);
+ local node = jid_split(to);
vCard = st.deserialize(vcards:get(node)); -- load vCard for user or server
else
vCard = st.deserialize(vcards:get(session.username));-- load user's own vCard
diff --git a/plugins/mod_vcard_legacy.lua b/plugins/mod_vcard_legacy.lua
index 5e75947a..107f20da 100644
--- a/plugins/mod_vcard_legacy.lua
+++ b/plugins/mod_vcard_legacy.lua
@@ -38,7 +38,7 @@ local simple_map = {
module:hook("iq-get/bare/vcard-temp:vCard", function (event)
local origin, stanza = event.origin, event.stanza;
local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username);
- local ok, id, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from);
+ local ok, _, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from);
local vcard_temp = st.stanza("vCard", { xmlns = "vcard-temp" });
if ok and vcard4_item then
@@ -105,26 +105,46 @@ module:hook("iq-get/bare/vcard-temp:vCard", function (event)
vcard_temp:tag("WORK"):up();
end
vcard_temp:up();
+ elseif tag.name == "impp" then
+ local uri = tag:get_child_text("uri");
+ if uri and uri:sub(1, 5) == "xmpp:" then
+ vcard_temp:text_tag("JABBERID", uri:sub(6))
+ end
+ elseif tag.name == "org" then
+ vcard_temp:tag("ORG")
+ :text_tag("ORGNAME", tag:get_child_text("text"))
+ :up();
+ end
+ end
+ else
+ local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", stanza.attr.from);
+ if ok and nick_item then
+ local nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick");
+ if nickname then
+ vcard_temp:text_tag("NICKNAME", nickname);
end
end
end
- local meta_ok, avatar_meta = pep_service:get_items("urn:xmpp:avatar:metadata", stanza.attr.from);
- local data_ok, avatar_data = pep_service:get_items("urn:xmpp:avatar:data", stanza.attr.from);
+ local ok, avatar_hash, meta = pep_service:get_last_item("urn:xmpp:avatar:metadata", stanza.attr.from);
+ if ok and avatar_hash then
- if data_ok then
- for _, hash in ipairs(avatar_data) do
- local meta = meta_ok and avatar_meta[hash];
- local data = avatar_data[hash];
- local info = meta and meta.tags[1]:get_child("info");
+ local info = meta.tags[1]:get_child("info");
+ if info then
vcard_temp:tag("PHOTO");
- if info and info.attr.type then
+
+ if info.attr.type then
vcard_temp:text_tag("TYPE", info.attr.type);
end
- if data then
- vcard_temp:text_tag("BINVAL", data.tags[1]:get_text());
- elseif info and info.attr.url then
+
+ if info.attr.url then
vcard_temp:text_tag("EXTVAL", info.attr.url);
+ elseif info.attr.id then
+ local data_ok, avatar_data = pep_service:get_items("urn:xmpp:avatar:data", stanza.attr.from, { info.attr.id });
+ if data_ok and avatar_data and avatar_data[info.attr.id] then
+ local data = avatar_data[info.attr.id];
+ vcard_temp:text_tag("BINVAL", data.tags[1]:get_text());
+ end
end
vcard_temp:up();
end
@@ -140,7 +160,7 @@ local node_defaults = {
};
function vcard_to_pep(vcard_temp)
- local avatars = {};
+ local avatar = {};
local vcard4 = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = "current" })
:tag("vcard", { xmlns = 'urn:ietf:params:xml:ns:vcard-4.0' });
@@ -216,6 +236,10 @@ function vcard_to_pep(vcard_temp)
vcard4:text_tag("text", "work");
end
vcard4:up():up():up();
+ elseif tag.name == "JABBERID" then
+ vcard4:tag("impp")
+ :text_tag("uri", "xmpp:" .. tag:get_text())
+ :up();
elseif tag.name == "PHOTO" then
local avatar_type = tag:get_child_text("TYPE");
local avatar_payload = tag:get_child_text("BINVAL");
@@ -225,7 +249,9 @@ function vcard_to_pep(vcard_temp)
local avatar_raw = base64_decode(avatar_payload);
local avatar_hash = sha1(avatar_raw, true);
- local avatar_meta = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
+ avatar.hash = avatar_hash;
+
+ avatar.meta = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
:tag("metadata", { xmlns="urn:xmpp:avatar:metadata" })
:tag("info", {
bytes = tostring(#avatar_raw),
@@ -233,36 +259,27 @@ function vcard_to_pep(vcard_temp)
type = avatar_type,
});
- local avatar_data = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
+ avatar.data = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
:tag("data", { xmlns="urn:xmpp:avatar:data" })
:text(avatar_payload);
- table.insert(avatars, { hash = avatar_hash, meta = avatar_meta, data = avatar_data });
end
end
end
- return vcard4, avatars;
+ return vcard4, avatar;
end
-function save_to_pep(pep_service, actor, vcard4, avatars)
- if avatars then
+function save_to_pep(pep_service, actor, vcard4, avatar)
+ if avatar then
if pep_service:purge("urn:xmpp:avatar:metadata", actor) then
pep_service:purge("urn:xmpp:avatar:data", actor);
end
- local avatar_defaults = node_defaults;
- if #avatars > 1 then
- avatar_defaults = {};
- for k,v in pairs(node_defaults) do
- avatar_defaults[k] = v;
- end
- avatar_defaults.max_items = #avatars;
- end
- for _, avatar in ipairs(avatars) do
- local ok, err = pep_service:publish("urn:xmpp:avatar:data", actor, avatar.hash, avatar.data, avatar_defaults);
+ if avatar.data and avatar.meta then
+ local ok, err = assert(pep_service:publish("urn:xmpp:avatar:data", actor, avatar.hash, avatar.data, node_defaults));
if ok then
- ok, err = pep_service:publish("urn:xmpp:avatar:metadata", actor, avatar.hash, avatar.meta, avatar_defaults);
+ ok, err = assert(pep_service:publish("urn:xmpp:avatar:metadata", actor, avatar.hash, avatar.meta, node_defaults));
end
if not ok then
return ok, err;
diff --git a/plugins/mod_websocket.lua b/plugins/mod_websocket.lua
index 60c76605..f0134b4a 100644
--- a/plugins/mod_websocket.lua
+++ b/plugins/mod_websocket.lua
@@ -33,18 +33,10 @@ local frame_buffer_limit = module:get_option_number("websocket_frame_buffer_limi
local frame_fragment_limit = module:get_option_number("websocket_frame_fragment_limit", 8);
local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5);
local consider_websocket_secure = module:get_option_boolean("consider_websocket_secure");
-local cross_domain = module:get_option_set("cross_domain_websocket", {});
-if cross_domain:contains("*") or cross_domain:contains(true) then
- cross_domain = true;
+local cross_domain = module:get_option("cross_domain_websocket");
+if cross_domain ~= nil then
+ module:log("info", "The 'cross_domain_websocket' option has been deprecated");
end
-
-local function check_origin(origin)
- if cross_domain == true then
- return true;
- end
- return cross_domain:contains(origin);
-end
-
local xmlns_framing = "urn:ietf:params:xml:ns:xmpp-framing";
local xmlns_streams = "http://etherx.jabber.org/streams";
local xmlns_client = "jabber:client";
@@ -79,6 +71,8 @@ local function session_close(session, reason)
local stream_error = st.stanza("stream:error");
if type(reason) == "string" then -- assume stream error
stream_error:tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' });
+ elseif st.is_stanza(reason) then
+ stream_error = reason;
elseif type(reason) == "table" then
if reason.condition then
stream_error:tag(reason.condition, stream_xmlns_attr):up();
@@ -88,11 +82,9 @@ local function session_close(session, reason)
if reason.extra then
stream_error:add_child(reason.extra);
end
- elseif reason.name then -- a stanza
- stream_error = reason;
end
end
- log("debug", "Disconnecting client, <stream:error> is: %s", tostring(stream_error));
+ log("debug", "Disconnecting client, <stream:error> is: %s", stream_error);
session.send(stream_error);
end
@@ -143,6 +135,14 @@ local function filter_open_close(data)
return data;
end
+local default_get_response_text = "It works! Now point your WebSocket client to this URL to connect to Prosody."
+local websocket_get_response_text = module:get_option_string("websocket_get_response_text", default_get_response_text)
+
+local default_get_response_body = [[<!DOCTYPE html><html><head><title>Websocket</title></head><body>
+<p>]]..websocket_get_response_text..[[</p>
+</body></html>]]
+local websocket_get_response_body = module:get_option_string("websocket_get_response_body", default_get_response_body)
+
local function validate_frame(frame, max_length)
local opcode, length = frame.opcode, frame.length;
@@ -207,12 +207,15 @@ function handle_request(event)
conn.starttls = false; -- Prevent mod_tls from believing starttls can be done
- if not request.headers.sec_websocket_key then
- response.headers.content_type = "text/html";
- return [[<!DOCTYPE html><html><head><title>Websocket</title></head><body>
- <p>It works! Now point your WebSocket client to this URL to connect to Prosody.</p>
- </body></html>]];
- end
+ if not request.headers.sec_websocket_key or request.method ~= "GET" then
+ return module:fire_event("http-message", {
+ response = event.response;
+ ---
+ title = "Prosody WebSocket endpoint";
+ message = websocket_get_response_text;
+ warning = not (consider_websocket_secure or request.secure) and "This endpoint is not considered secure!" or nil;
+ }) or websocket_get_response_body;
+ end
local wants_xmpp = contains_token(request.headers.sec_websocket_protocol or "", "xmpp");
@@ -221,11 +224,6 @@ function handle_request(event)
return 501;
end
- if not check_origin(request.headers.origin or "") then
- module:log("debug", "Origin %s is not allowed by 'cross_domain_websocket' [ %s ]", request.headers.origin or "(missing header)", cross_domain);
- return 403;
- end
-
local function websocket_close(code, message)
conn:write(build_close(code, message));
conn:close();
@@ -276,7 +274,7 @@ function handle_request(event)
-- See mod_http and #540
session.ip = request.ip;
- session.secure = consider_websocket_secure or session.secure;
+ session.secure = consider_websocket_secure or request.secure or session.secure;
session.websocket_request = request;
session.open_stream = session_open_stream;
@@ -350,41 +348,20 @@ local function keepalive(event)
end
end
-module:hook("c2s-read-timeout", keepalive, -0.9);
-
-module:depends("http");
-module:provides("http", {
- name = "websocket";
- default_path = "xmpp-websocket";
- route = {
- ["GET"] = handle_request;
- ["GET /"] = handle_request;
- };
-});
-
function module.add_host(module)
module:hook("c2s-read-timeout", keepalive, -0.9);
- if cross_domain ~= true then
- local url = require "socket.url";
- local ws_url = module:http_url("websocket", "xmpp-websocket");
- local url_components = url.parse(ws_url);
- -- The 'Origin' consists of the base URL without path
- url_components.path = nil;
- local this_origin = url.build(url_components);
- local local_cross_domain = module:get_option_set("cross_domain_websocket", { this_origin });
- if local_cross_domain:contains(true) then
- module:log("error", "cross_domain_websocket = true only works in the global section");
- return;
- end
+ module:depends("http");
+ module:provides("http", {
+ name = "websocket";
+ default_path = "xmpp-websocket";
+ route = {
+ ["GET"] = handle_request;
+ ["GET /"] = handle_request;
+ };
+ });
- -- Don't add / remove something added by another host
- -- This might be weird with random load order
- local_cross_domain:exclude(cross_domain);
- cross_domain:include(local_cross_domain);
- module:log("debug", "cross_domain = %s", tostring(cross_domain));
- function module.unload()
- cross_domain:exclude(local_cross_domain);
- end
- end
+ module:hook("c2s-read-timeout", keepalive, -0.9);
end
+
+module:add_host();
diff --git a/plugins/muc/hats.lib.lua b/plugins/muc/hats.lib.lua
new file mode 100644
index 00000000..358e5100
--- /dev/null
+++ b/plugins/muc/hats.lib.lua
@@ -0,0 +1,26 @@
+local st = require "util.stanza";
+local muc_util = module:require "muc/util";
+
+local xmlns_hats = "xmpp:prosody.im/protocol/hats:1";
+
+-- Strip any hats claimed by the client (to prevent spoofing)
+muc_util.add_filtered_namespace(xmlns_hats);
+
+
+module:hook("muc-build-occupant-presence", function (event)
+ local bare_jid = event.occupant and event.occupant.bare_jid or event.bare_jid;
+ local aff_data = event.room:get_affiliation_data(bare_jid);
+ local hats = aff_data and aff_data.hats;
+ if not hats then return; end
+ local hats_el;
+ for hat_id, hat_data in pairs(hats) do
+ if hat_data.active then
+ if not hats_el then
+ hats_el = st.stanza("hats", { xmlns = xmlns_hats });
+ end
+ hats_el:tag("hat", { uri = hat_id, title = hat_data.title }):up();
+ end
+ end
+ if not hats_el then return; end
+ event.stanza:add_direct_child(hats_el);
+end);
diff --git a/plugins/muc/history.lib.lua b/plugins/muc/history.lib.lua
index 0d69c97d..075b1890 100644
--- a/plugins/muc/history.lib.lua
+++ b/plugins/muc/history.lib.lua
@@ -48,16 +48,18 @@ module:hook("muc-config-form", function(event)
table.insert(event.form, {
name = "muc#roomconfig_historylength";
type = "text-single";
+ datatype = "xs:integer";
label = "Maximum number of history messages returned by room";
desc = "Specify the maximum number of previous messages that should be sent to users when they join the room";
- value = tostring(get_historylength(event.room));
+ value = get_historylength(event.room);
});
table.insert(event.form, {
name = 'muc#roomconfig_defaulthistorymessages',
type = 'text-single',
+ datatype = "xs:integer";
label = 'Default number of history messages returned by room',
desc = "Specify the number of previous messages sent to new users when they join the room";
- value = tostring(get_defaulthistorymessages(event.room))
+ value = get_defaulthistorymessages(event.room);
});
end, 70-5);
@@ -171,6 +173,10 @@ end, 50); -- Before subject(20)
-- add to history
module:hook("muc-add-history", function(event)
local room = event.room
+ if get_historylength(room) == 0 then
+ room._history = nil;
+ return;
+ end
local history = room._history;
if not history then history = {}; room._history = history; end
local stanza = st.clone(event.stanza);
@@ -180,9 +186,6 @@ module:hook("muc-add-history", function(event)
stanza:tag("delay", { -- XEP-0203
xmlns = "urn:xmpp:delay", from = room.jid, stamp = stamp
}):up();
- stanza:tag("x", { -- XEP-0091 (deprecated)
- xmlns = "jabber:x:delay", from = room.jid, stamp = datetime.legacy()
- }):up();
local entry = { stanza = stanza, timestamp = ts };
table.insert(history, entry);
while #history > get_historylength(room) do table.remove(history, 1) end
@@ -198,7 +201,27 @@ module:hook("muc-broadcast-message", function(event)
end);
module:hook("muc-message-is-historic", function (event)
- return event.stanza:get_child("body");
+ local stanza = event.stanza;
+ if stanza:get_child("no-store", "urn:xmpp:hints")
+ or stanza:get_child("no-permanent-store", "urn:xmpp:hints") then
+ -- XXX Experimental XEP
+ return false, "hint";
+ end
+ if stanza:get_child("store", "urn:xmpp:hints") then
+ return true, "hint";
+ end
+ if stanza:get_child("body") then
+ return true;
+ end
+ if stanza:get_child("encryption", "urn:xmpp:eme:0") then
+ -- Since we can't know what an encrypted message contains, we assume it's important
+ -- XXX Experimental XEP
+ return true, "encrypted";
+ end
+ if stanza:get_child(nil, "urn:xmpp:chat-markers:0") then
+ -- XXX Experimental XEP
+ return true, "marker";
+ end
end, -1);
return {
diff --git a/plugins/muc/language.lib.lua b/plugins/muc/language.lib.lua
index ee80806b..2ee2ba0f 100644
--- a/plugins/muc/language.lib.lua
+++ b/plugins/muc/language.lib.lua
@@ -32,6 +32,7 @@ local function add_form_option(event)
label = "Language tag for room (e.g. 'en', 'de', 'fr' etc.)";
type = "text-single";
desc = "Indicate the primary language spoken in this room";
+ datatype = "xs:language";
value = get_language(event.room) or "";
});
end
diff --git a/plugins/muc/lock.lib.lua b/plugins/muc/lock.lib.lua
index 062ab615..32f2647b 100644
--- a/plugins/muc/lock.lib.lua
+++ b/plugins/muc/lock.lib.lua
@@ -43,7 +43,7 @@ end
module:hook("muc-occupant-pre-join", function(event)
if not event.is_new_room and is_locked(event.room) then -- Deny entry
module:log("debug", "Room is locked, denying entry");
- event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found"));
+ event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found", nil, module.host));
return true;
end
end, -30);
diff --git a/plugins/muc/members_only.lib.lua b/plugins/muc/members_only.lib.lua
index 3220225f..b10dc120 100644
--- a/plugins/muc/members_only.lib.lua
+++ b/plugins/muc/members_only.lib.lua
@@ -121,9 +121,8 @@ module:hook("muc-occupant-pre-join", function(event)
local stanza = event.stanza;
local affiliation = room:get_affiliation(stanza.attr.from);
if valid_affiliations[affiliation or "none"] <= valid_affiliations.none then
- local reply = st.error_reply(stanza, "auth", "registration-required"):up();
- reply.tags[1].attr.code = "407";
- event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+ local reply = st.error_reply(stanza, "auth", "registration-required", nil, room.jid):up();
+ event.origin.send(reply);
return true;
end
end
@@ -139,7 +138,7 @@ module:hook("muc-pre-invite", function(event)
local inviter_affiliation = room:get_affiliation(stanza.attr.from) or "none";
local required_affiliation = room._data.allow_member_invites and "member" or "admin";
if valid_affiliations[inviter_affiliation] < valid_affiliations[required_affiliation] then
- event.origin.send(st.error_reply(stanza, "auth", "forbidden"));
+ event.origin.send(st.error_reply(stanza, "auth", "forbidden", nil, room.jid));
return true;
end
end
diff --git a/plugins/muc/mod_muc.lua b/plugins/muc/mod_muc.lua
index b9c7c859..5873b1a2 100644
--- a/plugins/muc/mod_muc.lua
+++ b/plugins/muc/mod_muc.lua
@@ -86,7 +86,17 @@ room_mt.get_registered_nick = register.get_registered_nick;
room_mt.get_registered_jid = register.get_registered_jid;
room_mt.handle_register_iq = register.handle_register_iq;
+local presence_broadcast = module:require "muc/presence_broadcast";
+room_mt.get_presence_broadcast = presence_broadcast.get;
+room_mt.set_presence_broadcast = presence_broadcast.set;
+room_mt.get_valid_broadcast_roles = presence_broadcast.get_valid_broadcast_roles;
+
+local occupant_id = module:require "muc/occupant_id";
+room_mt.get_salt = occupant_id.get_room_salt;
+room_mt.get_occupant_id = occupant_id.get_occupant_id;
+
local jid_split = require "util.jid".split;
+local jid_prep = require "util.jid".prep;
local jid_bare = require "util.jid".bare;
local st = require "util.stanza";
local cache = require "util.cache";
@@ -98,6 +108,7 @@ module:depends("disco");
module:add_identity("conference", "text", module:get_option_string("name", "Prosody Chatrooms"));
module:add_feature("http://jabber.org/protocol/muc");
module:depends "muc_unique"
+module:require "muc/hats";
module:require "muc/lock";
local function is_admin(jid)
@@ -129,7 +140,12 @@ local room_items_cache = {};
local function room_save(room, forced, savestate)
local node = jid_split(room.jid);
local is_persistent = persistent.get(room);
- room_items_cache[room.jid] = room:get_public() and room:get_name() or nil;
+ if room:get_public() then
+ room_items_cache[room.jid] = room:get_name() or "";
+ else
+ room_items_cache[room.jid] = nil;
+ end
+
if is_persistent or savestate then
persistent_rooms:set(nil, room.jid, true);
local data, state = room:freeze(savestate);
@@ -155,7 +171,11 @@ local rooms = cache.new(max_rooms or max_live_rooms, function (jid, room)
end
module:log("debug", "Evicting room %s", jid);
room_eviction();
- room_items_cache[room.jid] = room:get_public() and room:get_name() or nil;
+ if room:get_public() then
+ room_items_cache[room.jid] = room:get_name() or "";
+ else
+ room_items_cache[room.jid] = nil;
+ end
local ok, err = room_save(room, nil, true); -- Force to disk
if not ok then
module:log("error", "Failed to swap inactive room %s to disk: %s", jid, err);
@@ -163,6 +183,11 @@ local rooms = cache.new(max_rooms or max_live_rooms, function (jid, room)
end
end);
+local measure_rooms_size = module:measure("live_room", "amount");
+module:hook_global("stats-update", function ()
+ measure_rooms_size(rooms:count());
+end);
+
-- Automatically destroy empty non-persistent rooms
module:hook("muc-occupant-left",function(event)
local room = event.room
@@ -185,7 +210,7 @@ end
local function handle_broken_room(room, origin, stanza)
module:log("debug", "Returning error from broken room %s", room.jid);
- origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
+ origin.send(st.error_reply(stanza, "wait", "internal-server-error", nil, room.jid));
return true;
end
@@ -264,9 +289,13 @@ local function set_room_defaults(room, lang)
room:set_changesubject(module:get_option_boolean("muc_room_default_change_subject", room:get_changesubject()));
room:set_historylength(module:get_option_number("muc_room_default_history_length", room:get_historylength()));
room:set_language(lang or module:get_option_string("muc_room_default_language"));
+ room:set_presence_broadcast(module:get_option("muc_room_default_presence_broadcast", room:get_presence_broadcast()));
end
function create_room(room_jid, config)
+ if jid_bare(room_jid) ~= room_jid or not jid_prep(room_jid, true) then
+ return nil, "invalid-jid";
+ end
local exists = get_room_from_jid(room_jid);
if exists then
return nil, "room-exists";
@@ -325,13 +354,14 @@ module:hook("host-disco-items", function(event)
module:log("debug", "host-disco-items called");
if next(room_items_cache) ~= nil then
for jid, room_name in pairs(room_items_cache) do
+ if room_name == "" then room_name = nil; end
reply:tag("item", { jid = jid, name = room_name }):up();
end
else
for room in all_rooms() do
if not room:get_hidden() then
local jid, room_name = room.jid, room:get_name();
- room_items_cache[jid] = room_name;
+ room_items_cache[jid] = room_name or "";
reply:tag("item", { jid = jid, name = room_name }):up();
end
end
@@ -345,7 +375,7 @@ end, 1);
module:hook("muc-room-pre-create", function(event)
local origin, stanza = event.origin, event.stanza;
if not track_room(event.room) then
- origin.send(st.error_reply(stanza, "wait", "resource-constraint"));
+ origin.send(st.error_reply(stanza, "wait", "resource-constraint", nil, module.host));
return true;
end
end, -1000);
@@ -396,7 +426,7 @@ do
restrict_room_creation == "local" and
select(2, jid_split(user_jid)) == host_suffix
) then
- origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted"));
+ origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted", module.host));
return true;
end
end);
@@ -441,7 +471,7 @@ for event_name, method in pairs {
room = nil;
else
if stanza.attr.type ~= "error" then
- local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason)
+ local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason, module.host)
if room._data.newjid then
local uri = "xmpp:"..room._data.newjid.."?join";
reply:get_child("error"):child_with_name("gone"):text(uri);
@@ -454,17 +484,21 @@ for event_name, method in pairs {
if room == nil then
-- Watch presence to create rooms
- if stanza.attr.type == nil and stanza.name == "presence" then
+ if not jid_prep(room_jid, true) then
+ origin.send(st.error_reply(stanza, "modify", "jid-malformed", nil, module.host));
+ return true;
+ end
+ if stanza.attr.type == nil and stanza.name == "presence" and stanza:get_child("x", "http://jabber.org/protocol/muc") then
room = muclib.new_room(room_jid);
return room:handle_first_presence(origin, stanza);
elseif stanza.attr.type ~= "error" then
- origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+ origin.send(st.error_reply(stanza, "cancel", "item-not-found", nil, module.host));
return true;
else
return;
end
elseif room == false then -- Error loading room
- origin.send(st.error_reply(stanza, "wait", "resource-constraint"));
+ origin.send(st.error_reply(stanza, "wait", "resource-constraint", nil, module.host));
return true;
end
return room[method](room, origin, stanza);
@@ -483,6 +517,7 @@ do -- Ad-hoc commands
local t_concat = table.concat;
local adhoc_new = module:require "adhoc".new;
local adhoc_initial = require "util.adhoc".new_initial_data_form;
+ local adhoc_simple = require "util.adhoc".new_simple_form;
local array = require "util.array";
local dataforms_new = require "util.dataforms".new;
@@ -504,13 +539,59 @@ do -- Ad-hoc commands
end
return { status = "completed", error = { message = t_concat(errmsg, "\n") } };
end
- for _, room in ipairs(fields.rooms) do
- get_room_from_jid(room):destroy();
+ local destroyed = array();
+ for _, room_jid in ipairs(fields.rooms) do
+ local room = get_room_from_jid(room_jid);
+ if room and room:destroy() then
+ destroyed:push(room.jid);
+ end
end
- return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(fields.rooms, "\n") };
+ return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(destroyed, "\n") };
end);
local destroy_rooms_desc = adhoc_new("Destroy Rooms",
"http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin");
module:provides("adhoc", destroy_rooms_desc);
+
+
+ local set_affiliation_layout = dataforms_new {
+ -- FIXME wordsmith title, instructions, labels etc
+ title = "Set affiliation";
+
+ { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#set-affiliation" };
+ { name = "room", type = "jid-single", required = true, label = "Room"};
+ { name = "jid", type = "jid-single", required = true, label = "JID"};
+ { name = "affiliation", type = "list-single", required = true, label = "Affiliation",
+ options = { "owner"; "admin"; "member"; "none"; "outcast"; },
+ };
+ { name = "reason", type = "text-single", "Reason", }
+ };
+
+ local set_affiliation_handler = adhoc_simple(set_affiliation_layout, function (fields, errors)
+ if errors then
+ local errmsg = {};
+ for field, err in pairs(errors) do
+ errmsg[#errmsg + 1] = field .. ": " .. err;
+ end
+ return { status = "completed", error = { message = t_concat(errmsg, "\n") } };
+ end
+
+ local room = get_room_from_jid(fields.room);
+ if not room then
+ return { status = "canceled", error = { message = "No such room"; }; };
+ end
+ local ok, err, condition = room:set_affiliation(true, fields.jid, fields.affiliation, fields.reason);
+
+ if not ok then
+ return { status = "canceled", error = { message = "Affiliation change failed: "..err..":"..condition; }; };
+ end
+
+ return { status = "completed", info = "Affiliation updated",
+ };
+ end);
+
+ local set_affiliation_desc = adhoc_new("Set affiliation in room",
+ "http://prosody.im/protocol/muc#set-affiliation", set_affiliation_handler, "admin");
+
+ module:provides("adhoc", set_affiliation_desc);
end
diff --git a/plugins/muc/muc.lib.lua b/plugins/muc/muc.lib.lua
index f037c4f6..9124a70f 100644
--- a/plugins/muc/muc.lib.lua
+++ b/plugins/muc/muc.lib.lua
@@ -22,7 +22,8 @@ local jid_resource = require "util.jid".resource;
local resourceprep = require "util.encodings".stringprep.resourceprep;
local st = require "util.stanza";
local base64 = require "util.encodings".base64;
-local md5 = require "util.hashes".md5;
+local hmac_sha256 = require "util.hashes".hmac_sha256;
+local new_id = require "util.id".medium;
local log = module._log;
@@ -39,7 +40,7 @@ function room_mt:__tostring()
end
function room_mt.save()
- -- overriden by mod_muc.lua
+ -- overridden by mod_muc.lua
end
function room_mt:get_occupant_jid(real_jid)
@@ -215,15 +216,16 @@ local function can_see_real_jids(whois, occupant)
end
end
+
-- Broadcasts an occupant's presence to the whole room
-- Takes the x element that goes into the stanzas
-function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason)
+function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason, prev_role, force_unavailable, recipient)
local base_x = x.base or x;
-- Build real jid and (optionally) occupant jid template presences
local base_presence do
-- Try to use main jid's presence
local pr = occupant:get_presence();
- if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") then
+ if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") and not force_unavailable then
base_presence = st.clone(pr);
else -- user is leaving but didn't send a leave presence. make one for them
base_presence = st.presence {from = occupant.nick; type = "unavailable";};
@@ -236,7 +238,10 @@ function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason)
occupant = occupant; nick = nick; actor = actor;
reason = reason;
}
- module:fire_event("muc-broadcast-presence", event);
+ module:fire_event("muc-build-occupant-presence", event);
+ if not recipient then
+ module:fire_event("muc-broadcast-presence", event);
+ end
-- Allow muc-broadcast-presence listeners to change things
nick = event.nick;
@@ -279,18 +284,34 @@ function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason)
self_p = st.clone(base_presence):add_child(self_x);
end
- -- General populance
+ local function get_p(rec_occupant)
+ local pr;
+ if can_see_real_jids(whois, rec_occupant) then
+ pr = get_full_p();
+ elseif occupant.bare_jid == rec_occupant.bare_jid then
+ pr = self_p;
+ else
+ pr = get_anon_p();
+ end
+ return pr
+ end
+
+ if recipient then
+ return self:route_to_occupant(recipient, get_p(recipient));
+ end
+
+ local broadcast_roles = self:get_presence_broadcast();
+ -- General populace
for occupant_nick, n_occupant in self:each_occupant() do
if occupant_nick ~= occupant.nick then
- local pr;
- if can_see_real_jids(whois, n_occupant) then
- pr = get_full_p();
- elseif occupant.bare_jid == n_occupant.bare_jid then
- pr = self_p;
- else
- pr = get_anon_p();
+ local pr = get_p(n_occupant);
+ if broadcast_roles[occupant.role or "none"] or force_unavailable then
+ self:route_to_occupant(n_occupant, pr);
+ elseif prev_role and broadcast_roles[prev_role] then
+ pr.attr.type = 'unavailable';
+ self:route_to_occupant(n_occupant, pr);
end
- self:route_to_occupant(n_occupant, pr);
+
end
end
@@ -303,6 +324,7 @@ function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason)
-- use their own presences as templates
for full_jid, pr in occupant:each_session() do
pr = st.clone(pr);
+ module:fire_event("muc-build-occupant-presence", { room = self, occupant = occupant, stanza = pr });
pr.attr.to = full_jid;
pr:add_child(self_x);
self:route_stanza(pr);
@@ -312,25 +334,40 @@ end
function room_mt:send_occupant_list(to, filter)
local to_bare = jid_bare(to);
- local is_anonymous = false;
- local whois = self:get_whois();
- if whois ~= "anyone" then
- local affiliation = self:get_affiliation(to);
- if affiliation ~= "admin" and affiliation ~= "owner" then
- local occupant = self:get_occupant_by_real_jid(to);
- if not (occupant and can_see_real_jids(whois, occupant)) then
- is_anonymous = true;
- end
- end
- end
+ local broadcast_roles = self:get_presence_broadcast();
+ local is_anonymous = self:is_anonymous_for(to);
+ local broadcast_bare_jids = {}; -- Track which bare JIDs we have sent presence for
for occupant_jid, occupant in self:each_occupant() do
+ broadcast_bare_jids[occupant.bare_jid] = true;
if filter == nil or filter(occupant_jid, occupant) then
local x = st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'});
self:build_item_list(occupant, x, is_anonymous and to_bare ~= occupant.bare_jid); -- can always see your own jids
local pres = st.clone(occupant:get_presence());
pres.attr.to = to;
pres:add_child(x);
- self:route_stanza(pres);
+ module:fire_event("muc-build-occupant-presence", { room = self, occupant = occupant, stanza = pres });
+ if to_bare == occupant.bare_jid or broadcast_roles[occupant.role or "none"] then
+ self:route_stanza(pres);
+ end
+ end
+ end
+ if broadcast_roles.none then
+ -- Broadcast stanzas for affiliated users not currently in the MUC
+ for affiliated_jid, affiliation, affiliation_data in self:each_affiliation() do
+ local nick = affiliation_data and affiliation_data.reserved_nickname;
+ if (nick or not is_anonymous) and not broadcast_bare_jids[affiliated_jid]
+ and (filter == nil or filter(affiliated_jid, nil)) then
+ local from = nick and (self.jid.."/"..nick) or self.jid;
+ local pres = st.presence({ to = to, from = from, type = "unavailable" })
+ :tag("x", { xmlns = 'http://jabber.org/protocol/muc#user' })
+ :tag("item", {
+ affiliation = affiliation;
+ role = "none";
+ nick = nick;
+ jid = not is_anonymous and affiliated_jid or nil }):up()
+ :up();
+ self:route_stanza(pres);
+ end
end
end
end
@@ -373,13 +410,14 @@ function room_mt:handle_kickable(origin, stanza) -- luacheck: ignore 212
local real_jid = stanza.attr.from;
local occupant = self:get_occupant_by_real_jid(real_jid);
if occupant == nil then return nil; end
- local type, condition, text = stanza:get_error();
+ local _, condition, text = stanza:get_error();
local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error");
if text and self:get_whois() == "anyone" then
error_message = error_message..": "..text;
end
occupant:set_session(real_jid, st.presence({type="unavailable"})
:tag('status'):text(error_message));
+ local orig_role = occupant.role;
local is_last_session = occupant.jid == real_jid;
if is_last_session then
occupant.role = nil;
@@ -389,9 +427,13 @@ function room_mt:handle_kickable(origin, stanza) -- luacheck: ignore 212
if is_last_session then
x:tag("status", {code = "333"});
end
- self:publicise_occupant_status(new_occupant or occupant, x);
+ self:publicise_occupant_status(new_occupant or occupant, x, nil, nil, nil, orig_role);
if is_last_session then
- module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+ module:fire_event("muc-occupant-left", {
+ room = self;
+ nick = occupant.nick;
+ occupant = occupant;
+ });
end
return true;
end
@@ -406,36 +448,48 @@ module:hook("muc-occupant-pre-join", function(event)
local room, stanza = event.room, event.stanza;
local affiliation = room:get_affiliation(stanza.attr.from);
if affiliation == "outcast" then
- local reply = st.error_reply(stanza, "auth", "forbidden"):up();
- reply.tags[1].attr.code = "403";
- event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+ local reply = st.error_reply(stanza, "auth", "forbidden", nil, room.jid):up();
+ event.origin.send(reply);
return true;
end
end, -10);
module:hook("muc-occupant-pre-join", function(event)
+ local room = event.room;
local nick = jid_resource(event.occupant.nick);
if not nick:find("%S") then
- event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden"));
+ event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden", room.jid));
return true;
end
end, 1);
module:hook("muc-occupant-pre-change", function(event)
+ local room = event.room;
if not jid_resource(event.dest_occupant.nick):find("%S") then
- event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden"));
+ event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden", room.jid));
return true;
end
end, 1);
-function room_mt:handle_first_presence(origin, stanza)
- if not stanza:get_child("x", "http://jabber.org/protocol/muc") then
- module:log("debug", "Room creation without <x>, possibly desynced");
+module:hook("muc-occupant-pre-join", function(event)
+ local room = event.room;
+ local nick = jid_resource(event.occupant.nick);
+ if not resourceprep(nick, true) then -- strict
+ event.origin.send(st.error_reply(event.stanza, "modify", "jid-malformed", "Nickname must pass strict validation", room.jid));
+ return true;
+ end
+end, 2);
- origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+module:hook("muc-occupant-pre-change", function(event)
+ local room = event.room;
+ local nick = jid_resource(event.dest_occupant.nick);
+ if not resourceprep(nick, true) then -- strict
+ event.origin.send(st.error_reply(event.stanza, "modify", "jid-malformed", "Nickname must pass strict validation", room.jid));
return true;
end
+end, 2);
+function room_mt:handle_first_presence(origin, stanza)
local real_jid = stanza.attr.from;
local dest_jid = stanza.attr.to;
local bare_jid = jid_bare(real_jid);
@@ -495,6 +549,72 @@ function room_mt:handle_first_presence(origin, stanza)
return true;
end
+
+function room_mt:is_anonymous_for(jid)
+ local is_anonymous = false;
+ local whois = self:get_whois();
+ if whois ~= "anyone" then
+ local affiliation = self:get_affiliation(jid);
+ if affiliation ~= "admin" and affiliation ~= "owner" then
+ local occupant = self:get_occupant_by_real_jid(jid);
+ if not (occupant and can_see_real_jids(whois, occupant)) then
+ is_anonymous = true;
+ end
+ end
+ end
+ return is_anonymous;
+end
+
+
+function room_mt:build_unavailable_presence(from_muc_jid, to_jid)
+ local nick = jid_resource(from_muc_jid);
+ local from_jid = self:get_registered_jid(nick);
+ if (not from_jid) then
+ module:log("debug", "Received presence probe for unavailable nickname that's not registered");
+ return;
+ end
+ local is_anonymous = self:is_anonymous_for(to_jid);
+ local affiliation = self:get_affiliation(from_jid) or "none";
+ local pr = st.presence({ to = to_jid, from = from_muc_jid, type = "unavailable" })
+ :tag("x", { xmlns = 'http://jabber.org/protocol/muc#user' })
+ :tag("item", {
+ affiliation = affiliation;
+ role = "none";
+ nick = nick;
+ jid = not is_anonymous and from_jid or nil }):up()
+ :up();
+
+ local x = pr:get_child("x", "http://jabber.org/protocol/muc");
+ local event = {
+ room = self; stanza = pr; x = x;
+ bare_jid = from_jid;
+ nick = nick;
+ }
+ module:fire_event("muc-build-occupant-presence", event);
+ return event.stanza;
+end
+
+function room_mt:respond_to_probe(origin, stanza, probing_occupant)
+ if probing_occupant == nil then
+ origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid));
+ return;
+ end
+
+ local from_muc_jid = stanza.attr.to;
+ local probed_occupant = self:get_occupant_by_nick(from_muc_jid);
+ if probed_occupant == nil then
+ local to_jid = stanza.attr.from;
+ local pr = self:build_unavailable_presence(from_muc_jid, to_jid);
+ if pr then
+ self:route_stanza(pr);
+ end
+ return;
+ end
+ local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"});
+ self:publicise_occupant_status(probed_occupant, x, nil, nil, nil, nil, false, probing_occupant);
+end
+
+
function room_mt:handle_normal_presence(origin, stanza)
local type = stanza.attr.type;
local real_jid = stanza.attr.from;
@@ -505,7 +625,7 @@ function room_mt:handle_normal_presence(origin, stanza)
if orig_occupant == nil and not muc_x and stanza.attr.type == nil then
module:log("debug", "Attempted join without <x>, possibly desynced");
origin.send(st.error_reply(stanza, "cancel", "item-not-found",
- "You must join the room before sending presence updates"));
+ "You are not currently connected to this chat", self.jid));
return true;
end
@@ -514,6 +634,9 @@ function room_mt:handle_normal_presence(origin, stanza)
if type == "unavailable" then
if orig_occupant == nil then return true; end -- Unavailable from someone not in the room
-- dest_occupant = nil
+ elseif type == "probe" then
+ self:respond_to_probe(origin, stanza, orig_occupant)
+ return true;
elseif orig_occupant and orig_occupant.nick == stanza.attr.to then -- Just a presence update
log("debug", "presence update for %s from session %s", orig_occupant.nick, real_jid);
dest_occupant = orig_occupant;
@@ -567,15 +690,15 @@ function room_mt:handle_normal_presence(origin, stanza)
and bare_jid ~= jid_bare(dest_occupant.bare_jid) then
-- new nick or has different bare real jid
log("debug", "%s couldn't join due to nick conflict: %s", real_jid, dest_occupant.nick);
- local reply = st.error_reply(stanza, "cancel", "conflict"):up();
- reply.tags[1].attr.code = "409";
- origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+ local reply = st.error_reply(stanza, "cancel", "conflict", nil, self.jid):up();
+ origin.send(reply);
return true;
end
-- Send presence stanza about original occupant
if orig_occupant ~= nil and orig_occupant ~= dest_occupant then
local orig_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+ local orig_role = orig_occupant.role;
local dest_nick;
if dest_occupant == nil then -- Session is leaving
log("debug", "session %s is leaving occupant %s", real_jid, orig_occupant.nick);
@@ -613,12 +736,12 @@ function room_mt:handle_normal_presence(origin, stanza)
x:tag("status", {code = "303";}):up();
x:tag("status", {code = "110";}):up();
self:route_stanza(generated_unavail:add_child(x));
- dest_nick = nil; -- set dest_nick to nil; so general populance doesn't see it for whole orig_occupant
+ dest_nick = nil; -- set dest_nick to nil; so general populace doesn't see it for whole orig_occupant
end
end
self:save_occupant(orig_occupant);
- self:publicise_occupant_status(orig_occupant, orig_x, dest_nick);
+ self:publicise_occupant_status(orig_occupant, orig_x, dest_nick, nil, nil, orig_role);
if is_last_orig_session then
module:fire_event("muc-occupant-left", {
@@ -639,7 +762,7 @@ function room_mt:handle_normal_presence(origin, stanza)
-- Send occupant list to newly joined or desynced user
self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212
-- Don't include self
- return occupant:get_presence(real_jid) == nil;
+ return (not occupant) or occupant:get_presence(real_jid) == nil;
end)
end
local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
@@ -650,7 +773,7 @@ function room_mt:handle_normal_presence(origin, stanza)
if nick_changed then
self_x:tag("status", {code="210"}):up();
end
- self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x});
+ self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x}, nil, nil, nil, orig_occupant and orig_occupant.role or nil);
if orig_occupant ~= nil and orig_occupant ~= dest_occupant and not is_last_orig_session then
-- If user is swapping and wasn't last original session
@@ -692,11 +815,11 @@ function room_mt:handle_presence_to_occupant(origin, stanza)
local type = stanza.attr.type;
if type == "error" then -- error, kick em out!
return self:handle_kickable(origin, stanza)
- elseif type == nil or type == "unavailable" then
+ elseif type == nil or type == "unavailable" or type == "probe" then
return self:handle_normal_presence(origin, stanza);
elseif type ~= 'result' then -- bad type
if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences
- origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error?
+ origin.send(st.error_reply(stanza, "modify", "bad-request", nil, self.jid)); -- FIXME correct error?
end
end
return true;
@@ -715,8 +838,9 @@ function room_mt:handle_iq_to_occupant(origin, stanza)
local from_occupant_jid = self:get_occupant_jid(from_jid);
if from_occupant_jid == nil then return nil; end
local session_jid
+ local salt = self:get_salt();
for to_jid in occupant:each_session() do
- if md5(to_jid) == to_jid_hash then
+ if hmac_sha256(salt, to_jid):sub(1,8) == to_jid_hash then
session_jid = to_jid;
break;
end
@@ -731,11 +855,11 @@ function room_mt:handle_iq_to_occupant(origin, stanza)
else -- Type is "get" or "set"
local current_nick = self:get_occupant_jid(from);
if not current_nick then
- origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat"));
+ origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid));
return true;
end
if not occupant then -- recipient not in room
- origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room"));
+ origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room", self.jid));
return true;
end
-- XEP-0410 MUC Self-Ping #1220
@@ -744,7 +868,8 @@ function room_mt:handle_iq_to_occupant(origin, stanza)
return true;
end
do -- construct_stanza_id
- stanza.attr.id = base64.encode(occupant.jid.."\0"..stanza.attr.id.."\0"..md5(from));
+ local salt = self:get_salt();
+ stanza.attr.id = base64.encode(occupant.jid.."\0"..stanza.attr.id.."\0"..hmac_sha256(salt, from):sub(1,8));
end
stanza.attr.from, stanza.attr.to = current_nick, occupant.jid;
log("debug", "%s sent private iq stanza to %s (%s)", from, to, occupant.jid);
@@ -764,12 +889,12 @@ function room_mt:handle_message_to_occupant(origin, stanza)
local type = stanza.attr.type;
if not current_nick then -- not in room
if type ~= "error" then
- origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat"));
+ origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid));
end
return true;
end
if type == "groupchat" then -- groupchat messages not allowed in PM
- origin.send(st.error_reply(stanza, "modify", "bad-request"));
+ origin.send(st.error_reply(stanza, "modify", "bad-request", nil, self.jid));
return true;
elseif type == "error" and is_kickable_error(stanza) then
log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid);
@@ -778,14 +903,16 @@ function room_mt:handle_message_to_occupant(origin, stanza)
local o_data = self:get_occupant_by_nick(to);
if not o_data then
- origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room"));
+ origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room", self.jid));
return true;
end
log("debug", "%s sent private message stanza to %s (%s)", from, to, o_data.jid);
stanza = muc_util.filter_muc_x(st.clone(stanza));
stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up();
stanza.attr.from = current_nick;
- self:route_to_occupant(o_data, stanza)
+ if module:fire_event("muc-private-message", { room = self, origin = origin, stanza = stanza }) ~= false then
+ self:route_to_occupant(o_data, stanza)
+ end
-- TODO: Remove x tag?
stanza.attr.from = from;
return true;
@@ -815,10 +942,12 @@ function room_mt:process_form(origin, stanza)
if form.attr.type == "cancel" then
origin.send(st.reply(stanza));
elseif form.attr.type == "submit" then
+ -- luacheck: ignore 231/errors
local fields, errors, present;
if form.tags[1] == nil then -- Instant room
fields, present = {}, {};
else
+ -- FIXME handle form errors
fields, errors, present = self:get_form_layout(stanza.attr.from):data(form);
if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then
origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration"));
@@ -873,19 +1002,28 @@ function room_mt:clear(x)
x = x or st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'});
local occupants_updated = {};
for nick, occupant in self:each_occupant() do -- luacheck: ignore 213
+ local prev_role = occupant.role;
occupant.role = nil;
self:save_occupant(occupant);
- occupants_updated[occupant] = true;
+ occupants_updated[occupant] = prev_role;
end
- for occupant in pairs(occupants_updated) do
- self:publicise_occupant_status(occupant, x);
- module:fire_event("muc-occupant-left", { room = self; nick = occupant.nick; occupant = occupant;});
+ for occupant, prev_role in pairs(occupants_updated) do
+ self:publicise_occupant_status(occupant, x, nil, nil, nil, prev_role);
+ module:fire_event("muc-occupant-left", {
+ room = self;
+ nick = occupant.nick;
+ occupant = occupant;
+ });
end
end
function room_mt:destroy(newjid, reason, password)
- local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"})
- :tag("destroy", {jid=newjid});
+ local x = st.stanza("x", { xmlns = "http://jabber.org/protocol/muc#user" });
+ local event = { room = self; newjid = newjid; reason = reason; password = password; x = x, allowed = true };
+ module:fire_event("muc-pre-room-destroy", event);
+ if not event.allowed then return false, event.error; end
+ newjid, reason, password = event.newjid, event.reason, event.password;
+ x:tag("destroy", { jid = newjid });
if reason then x:tag("reason"):text(reason):up(); end
if password then x:tag("password"):text(password):up(); end
x:up();
@@ -916,6 +1054,9 @@ function room_mt:handle_admin_query_set_command(origin, stanza)
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
@@ -972,7 +1113,7 @@ function room_mt:handle_admin_query_get_command(origin, stanza)
local _aff_rank = valid_affiliations[_aff or "none"];
local _rol = item.attr.role;
if _aff and _aff_rank and not _rol then
- -- You need to be at least an admin, and be requesting info about your affifiliation or lower
+ -- You need to be at least an admin, and be requesting info about your affiliation or lower
-- e.g. an admin can't ask for a list of owners
local affiliation_rank = valid_affiliations[affiliation or "none"];
if (affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank)
@@ -1035,8 +1176,12 @@ function room_mt:handle_owner_query_set_to_room(origin, stanza)
local newjid = child.attr.jid;
local reason = child:get_child_text("reason");
local password = child:get_child_text("password");
- self:destroy(newjid, reason, password);
- origin.send(st.reply(stanza));
+ local destroyed, err = self:destroy(newjid, reason, password);
+ if destroyed then
+ origin.send(st.reply(stanza));
+ else
+ origin.send(st.error_reply(stanza, err or "cancel", "not-allowed"));
+ end
return true;
elseif child.name == "x" and child.attr.xmlns == "jabber:x:data" then
return self:process_form(origin, stanza);
@@ -1049,10 +1194,18 @@ end
function room_mt:handle_groupchat_to_room(origin, stanza)
local from = stanza.attr.from;
local occupant = self:get_occupant_by_real_jid(from);
- if module:fire_event("muc-occupant-groupchat", {
- room = self; origin = origin; stanza = stanza; from = from; occupant = occupant;
- }) then return true; end
- stanza.attr.from = occupant.nick;
+ if not stanza.attr.id then
+ stanza.attr.id = new_id()
+ end
+ local event_data = {room = self; origin = origin; stanza = stanza; from = from; occupant = occupant};
+ if module:fire_event("muc-occupant-groupchat", event_data) then
+ return true;
+ end
+ if event_data.occupant then
+ stanza.attr.from = event_data.occupant.nick;
+ else
+ stanza.attr.from = self.jid;
+ end
self:broadcast_message(stanza);
stanza.attr.from = from;
return true;
@@ -1065,7 +1218,8 @@ module:hook("muc-occupant-groupchat", function(event)
event.origin.send(st.error_reply(event.stanza, "cancel", "not-acceptable", "You are not currently connected to this chat"));
return true;
elseif role_rank <= valid_roles.visitor then
- event.origin.send(st.error_reply(event.stanza, "auth", "forbidden"));
+ event.origin.send(st.error_reply(event.stanza, "auth", "forbidden",
+ "You do not currently have permission to speak in this chat"));
return true;
end
end, 50);
@@ -1218,7 +1372,7 @@ function room_mt:route_stanza(stanza) -- luacheck: ignore 212
end
function room_mt:get_affiliation(jid)
- local node, host, resource = jid_split(jid);
+ local node, host = jid_split(jid);
-- Affiliations are granted, revoked, and maintained based on the user's bare JID.
local bare = node and node.."@"..host or host;
local result = self._affiliations[bare];
@@ -1241,7 +1395,7 @@ end
function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
if not actor then return nil, "modify", "not-acceptable"; end;
- local node, host, resource = jid_split(jid);
+ local node, host = jid_split(jid);
if not host then return nil, "modify", "not-acceptable"; end
jid = jid_join(node, host); -- Bare
local is_host_only = node == nil;
@@ -1278,6 +1432,27 @@ function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
end
end
+ local event_data = {
+ room = self;
+ actor = actor;
+ jid = jid;
+ affiliation = affiliation or "none";
+ reason = reason;
+ previous_affiliation = target_affiliation or "none";
+ data = data and data or nil; -- coerce false to nil
+ previous_data = self._affiliation_data[jid] or nil;
+ };
+
+ module:fire_event("muc-pre-set-affiliation", event_data);
+ if event_data.allowed == false then
+ local err = event_data.error or { type = "cancel", condition = "not-allowed" };
+ return nil, err.type, err.condition;
+ end
+ if affiliation and not data and event_data.data then
+ -- Allow handlers to add data when none was going to be set
+ data = event_data.data;
+ end
+
-- Set in 'database'
self._affiliations[jid] = affiliation;
if not affiliation or data == false or (data ~= nil and next(data) == nil) then
@@ -1297,7 +1472,7 @@ function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
-- Outcast can be by host.
is_host_only and affiliation == "outcast" and select(2, jid_split(occupant.bare_jid)) == host
) then
- -- need to publcize in all cases; as affiliation in <item/> has changed.
+ -- need to publicize in all cases; as affiliation in <item/> has changed.
occupants_updated[occupant] = occupant.role;
if occupant.role ~= role and (
is_downgrade or
@@ -1322,16 +1497,20 @@ function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
if next(occupants_updated) ~= nil then
for occupant, old_role in pairs(occupants_updated) do
- self:publicise_occupant_status(occupant, x, nil, actor, reason);
+ self:publicise_occupant_status(occupant, x, nil, actor, reason, old_role);
if occupant.role == nil then
- module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+ module:fire_event("muc-occupant-left", {
+ room = self;
+ nick = occupant.nick;
+ occupant = occupant;
+ });
elseif is_semi_anonymous and
((old_role == "moderator" and occupant.role ~= "moderator") or
(old_role ~= "moderator" and occupant.role == "moderator")) then -- Has gained or lost moderator status
-- Send everyone else's presences (as jid visibility has changed)
for real_jid in occupant:each_session() do
self:send_occupant_list(real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433
- return occupant.bare_jid ~= jid;
+ return (not occupant) or occupant.bare_jid ~= jid;
end);
end
end
@@ -1348,16 +1527,8 @@ function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
self:save(true);
- module:fire_event("muc-set-affiliation", {
- room = self;
- actor = actor;
- jid = jid;
- affiliation = affiliation or "none";
- reason = reason;
- previous_affiliation = target_affiliation;
- data = data and data or nil; -- coerce false to nil
- in_room = next(occupants_updated) ~= nil;
- });
+ event_data.in_room = next(occupants_updated) ~= nil;
+ module:fire_event("muc-set-affiliation", event_data);
return true;
end
@@ -1371,11 +1542,70 @@ function room_mt:get_affiliation_data(jid, key)
return data;
end
+function room_mt:set_affiliation_data(jid, key, value)
+ if key == nil then return nil, "invalid key"; end
+ local data = self._affiliation_data[jid];
+ if not data then
+ if value == nil then return true; end
+ data = {};
+ self._affiliation_data[jid] = data;
+ end
+ local old_value = data[key];
+ data[key] = value;
+ if old_value ~= value then
+ module:fire_event("muc-set-affiliation-data/"..key, {
+ room = self;
+ jid = jid;
+ key = key;
+ value = value;
+ old_value = old_value;
+ });
+ end
+ self:save(true);
+ return true;
+end
+
function room_mt:get_role(nick)
local occupant = self:get_occupant_by_nick(nick);
return occupant and occupant.role or nil;
end
+function room_mt:may_set_role(actor, occupant, role)
+ local event = {
+ room = self,
+ actor = actor,
+ occupant = occupant,
+ role = role,
+ };
+
+ module:fire_event("muc-pre-set-role", event);
+ if event.allowed ~= nil then
+ return event.allowed, event.error, event.condition;
+ end
+
+ -- 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
+ 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
+ end
+
+ -- Need to be in the room and a moderator
+ local actor_occupant = self:get_occupant_by_real_jid(actor);
+ if not actor_occupant or actor_occupant.role ~= "moderator" then
+ return nil, "cancel", "not-allowed";
+ end
+
+ return true;
+end
+
function room_mt:set_role(actor, occupant_jid, role, reason)
if not actor then return nil, "modify", "not-acceptable"; end
@@ -1390,24 +1620,9 @@ function room_mt:set_role(actor, occupant_jid, role, reason)
if actor == true then
actor = nil -- So we can pass it safely to 'publicise_occupant_status' below
else
- -- 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
- 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
- end
-
- -- Need to be in the room and a moderator
- local actor_occupant = self:get_occupant_by_real_jid(actor);
- if not actor_occupant or actor_occupant.role ~= "moderator" then
- return nil, "cancel", "not-allowed";
+ local allowed, err, condition = self:may_set_role(actor, occupant, role)
+ if not allowed then
+ return allowed, err, condition;
end
end
@@ -1415,11 +1630,17 @@ function room_mt:set_role(actor, occupant_jid, role, reason)
if not role then
x:tag("status", {code = "307"}):up();
end
+
+ local prev_role = occupant.role;
occupant.role = role;
self:save_occupant(occupant);
- self:publicise_occupant_status(occupant, x, nil, actor, reason);
+ self:publicise_occupant_status(occupant, x, nil, actor, reason, prev_role);
if role == nil then
- module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+ module:fire_event("muc-occupant-left", {
+ room = self;
+ nick = occupant.nick;
+ occupant = occupant;
+ });
end
return true;
end
@@ -1441,7 +1662,7 @@ function _M.new_room(jid, config)
}, room_mt);
end
-local new_format = module:get_option_boolean("new_muc_storage_format", false);
+local new_format = module:get_option_boolean("new_muc_storage_format", true);
function room_mt:freeze(live)
local frozen, state;
@@ -1505,7 +1726,7 @@ function _M.restore_room(frozen, state)
else
-- New storage format
for jid, data in pairs(frozen) do
- local node, host, resource = jid_split(jid);
+ local _, host, resource = jid_split(jid);
if host:sub(1,1) ~= "_" and not resource and type(data) == "string" then
-- bare jid: affiliation
room._affiliations[jid] = data;
diff --git a/plugins/muc/name.lib.lua b/plugins/muc/name.lib.lua
index 37fe1259..5d73e74d 100644
--- a/plugins/muc/name.lib.lua
+++ b/plugins/muc/name.lib.lua
@@ -7,10 +7,8 @@
-- COPYING file in the source package for more information.
--
-local jid_split = require "util.jid".split;
-
local function get_name(room)
- return room._data.name or jid_split(room.jid);
+ return room._data.name;
end
local function set_name(room, name)
diff --git a/plugins/muc/occupant_id.lib.lua b/plugins/muc/occupant_id.lib.lua
new file mode 100644
index 00000000..1d310b3d
--- /dev/null
+++ b/plugins/muc/occupant_id.lib.lua
@@ -0,0 +1,76 @@
+-- Implementation of https://xmpp.org/extensions/inbox/occupant-id.html
+-- XEP-0421: Anonymous unique occupant identifiers for MUCs
+
+-- (C) 2020 Maxime “pep” Buquet <pep@bouah.net>
+-- (C) 2020 Matthew Wild <mwild1@gmail.com>
+
+local uuid = require "util.uuid";
+local hmac_sha256 = require "util.hashes".hmac_sha256;
+local b64encode = require "util.encodings".base64.encode;
+
+local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
+
+local function get_room_salt(room)
+ local salt = room._data.occupant_id_salt;
+ if not salt then
+ salt = uuid.generate();
+ room._data.occupant_id_salt = salt;
+ end
+ return salt;
+end
+
+local function get_occupant_id(room, occupant)
+ if occupant.stable_id then
+ return occupant.stable_id;
+ end
+
+ local salt = get_room_salt(room)
+
+ occupant.stable_id = b64encode(hmac_sha256(occupant.bare_jid, salt));
+
+ return occupant.stable_id;
+end
+
+local function update_occupant(event)
+ local stanza, room, occupant, dest_occupant = event.stanza, event.room, event.occupant, event.dest_occupant;
+
+ -- "muc-occupant-pre-change" provides "dest_occupant" but not "occupant".
+ if dest_occupant ~= nil then
+ occupant = dest_occupant;
+ end
+
+ -- strip any existing <occupant-id/> tags to avoid forgery
+ stanza:remove_children("occupant-id", xmlns_occupant_id);
+
+ local unique_id = get_occupant_id(room, occupant);
+ stanza:tag("occupant-id", { xmlns = xmlns_occupant_id, id = unique_id }):up();
+end
+
+local function muc_private(event)
+ local stanza, room = event.stanza, event.room;
+ local occupant = room._occupants[stanza.attr.from];
+
+ update_occupant({
+ stanza = stanza,
+ room = room,
+ occupant = occupant,
+ });
+end
+
+if module:get_option_boolean("muc_occupant_id", true) then
+ module:add_feature(xmlns_occupant_id);
+ module:hook("muc-disco#info", function (event)
+ event.reply:tag("feature", { var = xmlns_occupant_id }):up();
+ end);
+
+ module:hook("muc-broadcast-presence", update_occupant);
+ module:hook("muc-occupant-pre-join", update_occupant);
+ module:hook("muc-occupant-pre-change", update_occupant);
+ module:hook("muc-occupant-groupchat", update_occupant);
+ module:hook("muc-private-message", muc_private);
+end
+
+return {
+ get_room_salt = get_room_salt;
+ get_occupant_id = get_occupant_id;
+};
diff --git a/plugins/muc/password.lib.lua b/plugins/muc/password.lib.lua
index 1f4b2add..dd3cb658 100644
--- a/plugins/muc/password.lib.lua
+++ b/plugins/muc/password.lib.lua
@@ -50,9 +50,8 @@ module:hook("muc-occupant-pre-join", function(event)
if get_password(room) ~= password then
local from, to = stanza.attr.from, stanza.attr.to;
module:log("debug", "%s couldn't join due to invalid password: %s", from, to);
- local reply = st.error_reply(stanza, "auth", "not-authorized"):up();
- reply.tags[1].attr.code = "401";
- event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+ local reply = st.error_reply(stanza, "auth", "not-authorized", nil, room.jid):up();
+ event.origin.send(reply);
return true;
end
end, -20);
diff --git a/plugins/muc/presence_broadcast.lib.lua b/plugins/muc/presence_broadcast.lib.lua
new file mode 100644
index 00000000..82a89fee
--- /dev/null
+++ b/plugins/muc/presence_broadcast.lib.lua
@@ -0,0 +1,83 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+
+local valid_roles = { "none", "visitor", "participant", "moderator" };
+local default_broadcast = {
+ visitor = true;
+ participant = true;
+ moderator = true;
+};
+
+local function get_presence_broadcast(room)
+ return room._data.presence_broadcast or default_broadcast;
+end
+
+local function set_presence_broadcast(room, broadcast_roles)
+ broadcast_roles = broadcast_roles or default_broadcast;
+
+ local changed = false;
+ local old_broadcast_roles = get_presence_broadcast(room);
+ for _, role in ipairs(valid_roles) do
+ if old_broadcast_roles[role] ~= broadcast_roles[role] then
+ changed = true;
+ end
+ end
+
+ if not changed then return false; end
+
+ room._data.presence_broadcast = broadcast_roles;
+
+ for _, occupant in room:each_occupant() do
+ local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+ local role = occupant.role or "none";
+ if broadcast_roles[role] and not old_broadcast_roles[role] then
+ -- Presence broadcast is now enabled, so announce existing user
+ room:publicise_occupant_status(occupant, x);
+ elseif old_broadcast_roles[role] and not broadcast_roles[role] then
+ -- Presence broadcast is now disabled, so mark existing user as unavailable
+ room:publicise_occupant_status(occupant, x, nil, nil, nil, nil, true);
+ end
+ end
+
+ return true;
+end
+
+module:hook("muc-config-form", function(event)
+ local values = {};
+ for role, value in pairs(get_presence_broadcast(event.room)) do
+ if value then
+ values[#values + 1] = role;
+ end
+ end
+
+ table.insert(event.form, {
+ name = "muc#roomconfig_presencebroadcast";
+ type = "list-multi";
+ label = "Only show participants with roles:";
+ value = values;
+ options = valid_roles;
+ });
+end, 70-7);
+
+module:hook("muc-config-submitted/muc#roomconfig_presencebroadcast", function(event)
+ local broadcast_roles = {};
+ for _, role in ipairs(event.value) do
+ broadcast_roles[role] = true;
+ end
+ if set_presence_broadcast(event.room, broadcast_roles) then
+ event.status_codes["104"] = true;
+ end
+end);
+
+return {
+ get = get_presence_broadcast;
+ set = set_presence_broadcast;
+};
diff --git a/plugins/muc/register.lib.lua b/plugins/muc/register.lib.lua
index 95ed1a84..84045f33 100644
--- a/plugins/muc/register.lib.lua
+++ b/plugins/muc/register.lib.lua
@@ -8,6 +8,10 @@ local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_registe
local enforce_nick = module:get_option_boolean("enforce_registered_nickname", false);
+-- Whether to include the current registration data as a dataform. Disabled
+-- by default currently as it hasn't been widely tested with clients.
+local include_reg_form = module:get_option_boolean("muc_registration_include_form", false);
+
-- reserved_nicks[nick] = jid
local function get_reserved_nicks(room)
if room._reserved_nicks then
@@ -15,8 +19,7 @@ local function get_reserved_nicks(room)
end
module:log("debug", "Refreshing reserved nicks...");
local reserved_nicks = {};
- for jid in room:each_affiliation() do
- local data = room._affiliation_data[jid];
+ for jid, _, data in room:each_affiliation() do
local nick = data and data.reserved_nickname;
module:log("debug", "Refreshed for %s: %s", jid, nick);
if nick then
@@ -54,9 +57,23 @@ end);
local registration_form = dataforms.new {
{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#register" },
- { name = "muc#register_roomnick", type = "text-single", label = "Nickname"},
+ { name = "muc#register_roomnick", type = "text-single", required = true, label = "Nickname"},
};
+module:handle_items("muc-registration-field", function (event)
+ module:log("debug", "Adding MUC registration form field: %s", event.item.name);
+ table.insert(registration_form, event.item);
+end, function (event)
+ module:log("debug", "Removing MUC registration form field: %s", event.item.name);
+ local removed_field_name = event.item.name;
+ for i, field in ipairs(registration_form) do
+ if field.name == removed_field_name then
+ table.remove(registration_form, i);
+ break;
+ end
+ end
+end);
+
local function enforce_nick_policy(event)
local origin, stanza = event.origin, event.stanza;
local room = assert(event.room); -- FIXME
@@ -67,8 +84,8 @@ local function enforce_nick_policy(event)
local reserved_by = get_registered_jid(room, requested_nick);
if reserved_by and reserved_by ~= jid_bare(stanza.attr.from) then
module:log("debug", "%s attempted to use nick %s reserved by %s", stanza.attr.from, requested_nick, reserved_by);
- local reply = st.error_reply(stanza, "cancel", "conflict"):up();
- origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+ local reply = st.error_reply(stanza, "cancel", "conflict", nil, room.jid):up();
+ origin.send(reply);
return true;
end
@@ -80,8 +97,8 @@ local function enforce_nick_policy(event)
event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick;
elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then
module:log("debug", "Attempt by %s to join as %s, but their reserved nick is %s", stanza.attr.from, requested_nick, nick);
- local reply = st.error_reply(stanza, "cancel", "not-acceptable"):up();
- origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+ local reply = st.error_reply(stanza, "cancel", "not-acceptable", nil, room.jid):up();
+ origin.send(reply);
return true;
end
end
@@ -116,7 +133,13 @@ local function handle_register_iq(room, origin, stanza)
reply:query("jabber:iq:register");
if registered_nick then
reply:tag("registered"):up();
- reply:tag("username"):text(registered_nick);
+ reply:tag("username"):text(registered_nick):up();
+ if include_reg_form then
+ local aff_data = room:get_affiliation_data(user_jid);
+ if aff_data then
+ reply:add_child(registration_form:form(aff_data, "result"));
+ end
+ end
origin.send(reply);
return true;
end
@@ -135,13 +158,25 @@ local function handle_register_iq(room, origin, stanza)
return true;
end
local form_tag = query:get_child("x", "jabber:x:data");
- local reg_data = form_tag and registration_form:data(form_tag);
+ if not form_tag then
+ origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform"));
+ return true;
+ end
+ local form_type, err = dataforms.get_type(form_tag);
+ if not form_type then
+ origin.send(st.error_reply(stanza, "modify", "bad-request", "Error with form: "..err));
+ return true;
+ elseif form_type ~= "http://jabber.org/protocol/muc#register" then
+ origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form"));
+ return true;
+ end
+ local reg_data = registration_form:data(form_tag);
if not reg_data then
origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form"));
return true;
end
-- Is the nickname valid?
- local desired_nick = resourceprep(reg_data["muc#register_roomnick"]);
+ local desired_nick = resourceprep(reg_data["muc#register_roomnick"], true);
if not desired_nick then
origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid Nickname"));
return true;
@@ -172,6 +207,13 @@ local function handle_register_iq(room, origin, stanza)
-- Checks passed, save the registration
if registered_nick ~= desired_nick then
local registration_data = { reserved_nickname = desired_nick };
+ module:fire_event("muc-registration-submitted", {
+ room = room;
+ origin = origin;
+ stanza = stanza;
+ submitted_data = reg_data;
+ affiliation_data = registration_data;
+ });
local ok, err_type, err_condition = room:set_affiliation(true, user_jid, affiliation or "member", nil, registration_data);
if not ok then
origin.send(st.error_reply(stanza, err_type, err_condition));
diff --git a/plugins/muc/subject.lib.lua b/plugins/muc/subject.lib.lua
index 14256bbc..3230817c 100644
--- a/plugins/muc/subject.lib.lua
+++ b/plugins/muc/subject.lib.lua
@@ -94,6 +94,12 @@ module:hook("muc-occupant-groupchat", function(event)
local stanza = event.stanza;
local subject = stanza:get_child("subject");
if subject then
+ if stanza:get_child("body") or stanza:get_child("thread") then
+ -- Note: A message with a <subject/> and a <body/> or a <subject/> and
+ -- a <thread/> is a legitimate message, but it SHALL NOT be interpreted
+ -- as a subject change.
+ return;
+ end
local room = event.room;
local occupant = event.occupant;
-- Role check for subject changes
diff --git a/plugins/muc/util.lib.lua b/plugins/muc/util.lib.lua
index 53a83fae..0877150f 100644
--- a/plugins/muc/util.lib.lua
+++ b/plugins/muc/util.lib.lua
@@ -41,18 +41,22 @@ function _M.is_kickable_error(stanza)
return kickable_error_conditions[cond];
end
-local muc_x_filters = {
- ["http://jabber.org/protocol/muc"] = true;
- ["http://jabber.org/protocol/muc#user"] = true;
-}
-local function muc_x_filter(tag)
- if muc_x_filters[tag.attr.xmlns] then
+local filtered_namespaces = module:shared("filtered-namespaces");
+filtered_namespaces["http://jabber.org/protocol/muc"] = true;
+filtered_namespaces["http://jabber.org/protocol/muc#user"] = true;
+
+local function muc_ns_filter(tag)
+ if filtered_namespaces[tag.attr.xmlns] then
return nil;
end
return tag;
end
function _M.filter_muc_x(stanza)
- return stanza:maptags(muc_x_filter);
+ return stanza:maptags(muc_ns_filter);
+end
+
+function _M.add_filtered_namespace(xmlns)
+ filtered_namespaces[xmlns] = true;
end
function _M.only_with_min_role(role)