diff options
Diffstat (limited to 'plugins')
106 files changed, 4516 insertions, 1809 deletions
diff --git a/plugins/adhoc/adhoc.lib.lua b/plugins/adhoc/adhoc.lib.lua index 4cf6911d..0ce45e19 100644 --- a/plugins/adhoc/adhoc.lib.lua +++ b/plugins/adhoc/adhoc.lib.lua @@ -4,7 +4,7 @@ -- COPYING file in the source package for more information. -- -local st, uuid = require "util.stanza", require "util.uuid"; +local st, uuid = require "prosody.util.stanza", require "prosody.util.uuid"; local xmlns_cmd = "http://jabber.org/protocol/commands"; @@ -23,10 +23,16 @@ end function _M.new(name, node, handler, permission) if not permission then error "adhoc.new() expects a permission argument, none given" - end - if permission == "user" then + elseif permission == "user" then error "the permission mode 'user' has been renamed 'any', please update your code" end + if permission == "admin" then + module:default_permission("prosody:admin", "adhoc:"..node); + permission = "check"; + elseif permission == "global_admin" then + module:default_permission("prosody:operator", "adhoc:"..node); + permission = "check"; + end return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = permission }; end @@ -34,6 +40,8 @@ function _M.handle_cmd(command, origin, stanza) local cmdtag = stanza.tags[1] local sessionid = cmdtag.attr.sessionid or uuid.generate(); local dataIn = { + origin = origin; + stanza = stanza; to = stanza.attr.to; from = stanza.attr.from; action = cmdtag.attr.action or "execute"; diff --git a/plugins/adhoc/mod_adhoc.lua b/plugins/adhoc/mod_adhoc.lua index 09a72075..8abfff99 100644 --- a/plugins/adhoc/mod_adhoc.lua +++ b/plugins/adhoc/mod_adhoc.lua @@ -5,28 +5,26 @@ -- COPYING file in the source package for more information. -- -local it = require "util.iterators"; -local st = require "util.stanza"; -local is_admin = require "core.usermanager".is_admin; -local jid_host = require "util.jid".host; +local it = require "prosody.util.iterators"; +local st = require "prosody.util.stanza"; +local jid_host = require "prosody.util.jid".host; local adhoc_handle_cmd = module:require "adhoc".handle_cmd; local xmlns_cmd = "http://jabber.org/protocol/commands"; local commands = {}; module:add_feature(xmlns_cmd); +local function check_permissions(event, node, command, execute) + return (command.permission == "check" and module:may("adhoc:"..node, event, not execute)) + or (command.permission == "local_user" and jid_host(event.stanza.attr.from) == module.host) + or (command.permission == "any"); +end + module:hook("host-disco-info-node", function (event) local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node; if commands[node] then - local from = stanza.attr.from; - local privileged = is_admin(from, stanza.attr.to); - local global_admin = is_admin(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 == "any") then + if check_permissions(event, node, command) then reply:tag("identity", { name = command.name, category = "automation", type = "command-node" }):up(); reply:tag("feature", { var = xmlns_cmd }):up(); @@ -44,20 +42,13 @@ module:hook("host-disco-info-node", function (event) end); module:hook("host-disco-items-node", function (event) - local stanza, reply, disco_node = event.stanza, event.reply, event.node; + local reply, disco_node = event.reply, event.node; if disco_node ~= xmlns_cmd then return; end - local from = stanza.attr.from; - local admin = is_admin(from, stanza.attr.to); - local global_admin = is_admin(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 == "any") then + if check_permissions(event, node, command) then reply:tag("item", { name = command.name, node = node, jid = module:get_host() }); reply:up(); @@ -71,20 +62,14 @@ module:hook("iq-set/host/"..xmlns_cmd..":command", function (event) local node = stanza.tags[1].attr.node local command = commands[node]; if command then - local from = stanza.attr.from; - local admin = is_admin(from, stanza.attr.to); - local global_admin = is_admin(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 + if not check_permissions(event, node, command, true) then origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up() - :add_child(commands[node]:cmdtag("canceled") + :add_child(command:cmdtag("canceled") :tag("note", {type="error"}):text("You don't have permission to execute this command"))); return true end -- User has permission now execute the command - adhoc_handle_cmd(commands[node], origin, stanza); + adhoc_handle_cmd(command, origin, stanza); return true; end end, 500); diff --git a/plugins/mod_admin_adhoc.lua b/plugins/mod_admin_adhoc.lua index d0b0d452..ee26b7e5 100644 --- a/plugins/mod_admin_adhoc.lua +++ b/plugins/mod_admin_adhoc.lua @@ -14,23 +14,25 @@ local t_sort = table.sort; local module_host = module:get_host(); -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_set_password = require "core.usermanager".set_password; -local hostmanager_activate = require "core.hostmanager".activate; -local hostmanager_deactivate = require "core.hostmanager".deactivate; -local rm_load_roster = require "core.rostermanager".load_roster; -local st, jid = require "util.stanza", require "util.jid"; -local timer_add_task = require "util.timer".add_task; -local dataforms_new = require "util.dataforms".new; -local array = require "util.array"; -local modulemanager = require "core.modulemanager"; +local keys = require "prosody.util.iterators".keys; +local usermanager_user_exists = require "prosody.core.usermanager".user_exists; +local usermanager_create_user = require "prosody.core.usermanager".create_user; +local usermanager_delete_user = require "prosody.core.usermanager".delete_user; +local usermanager_disable_user = require "prosody.core.usermanager".disable_user; +local usermanager_enable_user = require "prosody.core.usermanager".enable_user; +local usermanager_set_password = require "prosody.core.usermanager".set_password; +local hostmanager_activate = require "prosody.core.hostmanager".activate; +local hostmanager_deactivate = require "prosody.core.hostmanager".deactivate; +local rm_load_roster = require "prosody.core.rostermanager".load_roster; +local st, jid = require "prosody.util.stanza", require "prosody.util.jid"; +local timer_add_task = require "prosody.util.timer".add_task; +local dataforms_new = require "prosody.util.dataforms".new; +local array = require "prosody.util.array"; +local modulemanager = require "prosody.core.modulemanager"; local core_post_stanza = prosody.core_post_stanza; -local adhoc_simple = require "util.adhoc".new_simple_form; -local adhoc_initial = require "util.adhoc".new_initial_data_form; -local set = require"util.set"; +local adhoc_simple = require "prosody.util.adhoc".new_simple_form; +local adhoc_initial = require "prosody.util.adhoc".new_initial_data_form; +local set = require"prosody.util.set"; module:depends("adhoc"); local adhoc_new = module:require "adhoc".new; @@ -152,6 +154,66 @@ local delete_user_command_handler = adhoc_simple(delete_user_layout, function(fi "The following accounts could not be deleted:\n"..t_concat(failed, "\n") or "") }; end); +local disable_user_layout = dataforms_new{ + title = "Disabling a User"; + instructions = "Fill out this form to disable a user."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjids", type = "jid-multi", required = true, label = "The Jabber ID(s) to disable" }; +}; + +local disable_user_command_handler = adhoc_simple(disable_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 = jid.split(aJID); + if (host == module_host) and usermanager_user_exists(username, host) and usermanager_disable_user(username, host) then + module:log("info", "User %s has been disabled by %s", aJID, jid.bare(data.from)); + succeeded[#succeeded+1] = aJID; + else + module:log("debug", "Tried to disable non-existent user %s", aJID); + failed[#failed+1] = aJID; + end + end + return {status = "completed", info = (#succeeded ~= 0 and + "The following accounts were successfully disabled:\n"..t_concat(succeeded, "\n").."\n" or "").. + (#failed ~= 0 and + "The following accounts could not be disabled:\n"..t_concat(failed, "\n") or "") }; +end); + +local enable_user_layout = dataforms_new{ + title = "Re-Enable a User"; + instructions = "Fill out this form to enable a user."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjids", type = "jid-multi", required = true, label = "The Jabber ID(s) to re-enable" }; +}; + +local enable_user_command_handler = adhoc_simple(enable_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 = jid.split(aJID); + if (host == module_host) and usermanager_user_exists(username, host) and usermanager_enable_user(username, host) then + module:log("info", "User %s has been enabled by %s", aJID, jid.bare(data.from)); + succeeded[#succeeded+1] = aJID; + else + module:log("debug", "Tried to enable non-existent user %s", aJID); + failed[#failed+1] = aJID; + end + end + return {status = "completed", info = (#succeeded ~= 0 and + "The following accounts were successfully enabled:\n"..t_concat(succeeded, "\n").."\n" or "").. + (#failed ~= 0 and + "The following accounts could not be enabled:\n"..t_concat(failed, "\n") or "") }; +end); + -- Ending a user's session local function disconnect_user(match_jid) local node, hostname, givenResource = jid.split(match_jid); @@ -804,6 +866,8 @@ local add_user_desc = adhoc_new("Add User", "http://jabber.org/protocol/admin#ad local change_user_password_desc = adhoc_new("Change User Password", "http://jabber.org/protocol/admin#change-user-password", change_user_password_command_handler, "admin"); 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 disable_user_desc = adhoc_new("Disable User", "http://jabber.org/protocol/admin#disable-user", disable_user_command_handler, "admin"); +local enable_user_desc = adhoc_new("Re-Enable User", "http://jabber.org/protocol/admin#reenable-user", enable_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_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"); @@ -824,6 +888,8 @@ module:provides("adhoc", add_user_desc); module:provides("adhoc", change_user_password_desc); module:provides("adhoc", config_reload_desc); module:provides("adhoc", delete_user_desc); +module:provides("adhoc", disable_user_desc); +module:provides("adhoc", enable_user_desc); module:provides("adhoc", end_user_session_desc); module:provides("adhoc", get_user_roster_desc); module:provides("adhoc", get_user_stats_desc); diff --git a/plugins/mod_admin_shell.lua b/plugins/mod_admin_shell.lua index f2da286b..e6b44f00 100644 --- a/plugins/mod_admin_shell.lua +++ b/plugins/mod_admin_shell.lua @@ -10,38 +10,41 @@ 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 hostmanager = require "prosody.core.hostmanager"; +local modulemanager = require "prosody.core.modulemanager"; +local s2smanager = require "prosody.core.s2smanager"; +local portmanager = require "prosody.core.portmanager"; +local helpers = require "prosody.util.helpers"; +local it = require "prosody.util.iterators"; +local server = require "prosody.net.server"; +local schema = require "prosody.util.jsonschema"; +local st = require "prosody.util.stanza"; local _G = _G; local prosody = _G.prosody; -local unpack = table.unpack or unpack; -- luacheck: ignore 113 -local iterators = require "util.iterators"; +local unpack = table.unpack; +local iterators = require "prosody.util.iterators"; local keys, values = iterators.keys, iterators.values; -local jid_bare, jid_split, jid_join, jid_compare = import("util.jid", "bare", "prepped_split", "join", "compare"); -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 jid_bare, jid_split, jid_join, jid_resource, jid_compare = import("prosody.util.jid", "bare", "prepped_split", "join", "resource", "compare"); +local set, array = require "prosody.util.set", require "prosody.util.array"; +local cert_verify_identity = require "prosody.util.x509".verify_identity; +local envload = require "prosody.util.envload".envload; +local envloadfile = require "prosody.util.envload".envloadfile; +local has_pposix, pposix = pcall(require, "prosody.util.pposix"); +local async = require "prosody.util.async"; +local serialization = require "prosody.util.serialization"; local serialize_config = serialization.new ({ fatal = false, unquoted = true}); -local time = require "util.time"; -local promise = require "util.promise"; +local time = require "prosody.util.time"; +local promise = require "prosody.util.promise"; +local logger = require "prosody.util.logger"; 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 format_number = require "prosody.util.human.units".format; +local format_table = require "prosody.util.human.io".table; local function capitalize(s) if not s then return end @@ -62,6 +65,86 @@ local commands = module:shared("commands") local def_env = module:shared("env"); local default_env_mt = { __index = def_env }; +local function new_section(section_desc) + return setmetatable({}, { + help = { + desc = section_desc; + commands = {}; + }; + }); +end + +local help_topics = {}; +local function help_topic(name) + return function (desc) + return function (content) + help_topics[name] = { + desc = desc; + content = content; + }; + end; + end +end + +-- Seed with default sections and their description text +help_topic "console" "Help regarding the console itself" [[ +Hey! Welcome to Prosody's admin console. +First thing, if you're ever wondering how to get out, simply type 'quit'. +Secondly, note that we don't support the full telnet protocol yet (it's coming) +so you may have trouble using the arrow keys, etc. depending on your system. + +For now we offer a couple of handy shortcuts: +!! - Repeat the last command +!old!new! - repeat the last command, but with 'old' replaced by 'new' + +For those well-versed in Prosody's internals, or taking instruction from those who are, +you can prefix a command with > to escape the console sandbox, and access everything in +the running server. Great fun, but be careful not to break anything :) +]]; + +local available_columns; --forward declaration so it is reachable from the help + +help_topic "columns" "Information about customizing session listings" (function (self, print) + print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]] + print [['columns' argument as described here.]] + print [[]] + print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]] + print [[Available columns are:]] + local meta_columns = { + { title = "ID"; width = 5 }; + { title = "Column Title"; width = 12 }; + { title = "Description"; width = 12 }; + }; + -- auto-adjust widths + for column, spec in pairs(available_columns) do + meta_columns[1].width = math.max(meta_columns[1].width or 0, #column); + meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or "")); + meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or "")); + end + local row = format_table(meta_columns, self.session.width) + print(row()); + for column, spec in iterators.sorted_pairs(available_columns) do + print(row({ column, spec.title, spec.description })); + end + print [[]] + print [[Most fields on the internal session structures can also be used as columns]] + -- Also, you can pass a table column specification directly, with mapper callback and all +end); + +help_topic "roles" "Show information about user roles" [[ +Roles may grant access or restrict users from certain operations. + +Built-in roles are: + prosody:guest - Guest/anonymous user + prosody:registered - Registered user + prosody:member - Provisioned user + prosody:admin - Host administrator + prosody:operator - Server administrator + +Roles can be assigned using the user management commands (see 'help user'). +]]; + + local function redirect_output(target, session) local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end }); env.dofile = function(name) @@ -83,8 +166,8 @@ function runner_callbacks:error(err) self.data.print("Error: "..tostring(err)); end -local function send_repl_output(session, line) - return session.send(st.stanza("repl-output"):text(tostring(line))); +local function send_repl_output(session, line, attr) + return session.send(st.stanza("repl-output", attr):text(tostring(line))); end function console:new_session(admin_session) @@ -99,8 +182,14 @@ function console:new_session(admin_session) end return send_repl_output(admin_session, table.concat(t, "\t")); end; + write = function (t) + return send_repl_output(admin_session, t, { eol = "0" }); + end; serialize = tostring; disconnect = function () admin_session:close(); end; + is_connected = function () + return not not admin_session.conn; + end }; session.env = setmetatable({}, default_env_mt); @@ -126,6 +215,11 @@ local function handle_line(event) session = console:new_session(event.origin); event.origin.shell_session = session; end + + local default_width = 132; -- The common default of 80 is a bit too narrow for e.g. s2s:show(), 132 was another common width for hardware terminals + local margin = 2; -- To account for '| ' when lines are printed + session.width = (tonumber(event.stanza.attr.width) or default_width)-margin; + local line = event.stanza:get_text(); local useglobalenv; @@ -135,7 +229,7 @@ local function handle_line(event) line = line:gsub("^>", ""); useglobalenv = true; else - local command = line:match("^%w+") or line:match("%p"); + local command = line:match("^(%w+) ") or line:match("^%w+$") or line:match("%p"); if commands[command] then commands[command](session, line); event.origin.send(result); @@ -201,148 +295,50 @@ module:hook("admin/repl-input", function (event) return true; end); +local function describe_command(s) + local section, name, args, desc = s:match("^([%w_]+):([%w_]+)%(([^)]*)%) %- (.+)$"); + if not section then + error("Failed to parse command description: "..s); + end + local command_help = getmetatable(def_env[section]).help.commands; + command_help[name] = { + desc = desc; + args = array.collect(args:gmatch("[%w_]+")):map(function (arg_name) + return { name = arg_name }; + end); + }; +end + -- Console commands -- -- These are simple commands, not valid standalone in Lua -local available_columns; --forward declaration so it is reachable from the help - +-- Help about individual topics is handled by def_env.help 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 [[]] - local row = format_table({ { title = "Section"; width = 7 }; { title = "Description"; width = "100%" } }) - print(row()) - print(row { "c2s"; "Commands to manage local client-to-server sessions" }) - print(row { "s2s"; "Commands to manage sessions between this server and others" }) - print(row { "http"; "Commands to inspect HTTP services" }) -- XXX plural but there is only one so far - print(row { "module"; "Commands to load/reload/unload modules/plugins" }) - print(row { "host"; "Commands to activate, deactivate and list virtual hosts" }) - print(row { "user"; "Commands to create and delete users, and change their passwords" }) - print(row { "roles"; "Show information about user roles" }) - print(row { "muc"; "Commands to create, list and manage chat rooms" }) - print(row { "stats"; "Commands to show internal statistics" }) - print(row { "server"; "Uptime, version, shutting down, etc." }) - print(row { "port"; "Commands to manage ports the server is listening on" }) - print(row { "dns"; "Commands to manage and inspect the internal DNS resolver" }) - print(row { "xmpp"; "Commands for sending XMPP stanzas" }) - print(row { "debug"; "Commands for debugging the server" }) - print(row { "config"; "Reloading the configuration, etc." }) - print(row { "columns"; "Information about customizing session listings" }) - print(row { "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) - Show current roles for an user]] - print [[user:setroles(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 == "roles" then - print [[Roles may grant access or restrict users from certain operations]] - print [[Built-in roles are:]] - print [[ prosody:admin - Administrator]] - print [[ (empty set) - Normal user]] - print [[]] - print [[The canonical role format looks like: { ["example:role"] = true }]] - print [[For convenience, the following formats are also accepted:]] - print [["admin" - short for "prosody:admin", the normal admin status (like the admins config option)]] - print [["example:role" - short for {["example:role"]=true}]] - print [[{"example:role"} - short for {["example:role"]=true}]] - 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) - Reference the specified MUC room to access MUC API methods]] - 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 - print [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern]] - print [[stats:show():cfgraph() - Show a cumulative frequency graph]] - print [[stats:show():histogram() - Show a histogram of selected metric]] - 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 :)]] - elseif section == "columns" then - print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]] - print [['columns' argument as described here.]] - print [[]] - print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]] - print [[Available columns are:]] - local meta_columns = { - { title = "ID"; width = 5 }; - { title = "Column Title"; width = 12 }; - { title = "Description"; width = 12 }; - }; - -- auto-adjust widths - for column, spec in pairs(available_columns) do - meta_columns[1].width = math.max(meta_columns[1].width or 0, #column); - meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or "")); - meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or "")); - end - local row = format_table(meta_columns, 120) - print(row()); - for column, spec in iterators.sorted_pairs(available_columns) do - print(row({ column, spec.title, spec.description })); - end - print [[]] - print [[Most fields on the internal session structures can also be used as columns]] - -- Also, you can pass a table column specification directly, with mapper callback and all + + local topic = data:match("^help (%w+)"); + if topic then + return def_env.help[topic]({ session = session }); + end + + print [[Commands are divided into multiple sections. For help on a particular section, ]] + print [[type: help SECTION (for example, 'help c2s'). Sections are: ]] + print [[]] + local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width) + print(row()) + for section_name, section in it.sorted_pairs(def_env) do + local section_mt = getmetatable(section); + local section_help = section_mt and section_mt.help; + print(row { section_name; section_help and section_help.desc or "" }); + end + + print(""); + + print [[In addition to info about commands, the following general topics are available:]] + + print(""); + for topic_name, topic_info in it.sorted_pairs(help_topics) do + print(topic_name .. " - "..topic_info.desc); end end @@ -350,10 +346,13 @@ end -- 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" }) +local serialize_defaults = module:get_option("console_prettyprint_settings", { + preset = "pretty"; + maxdepth = 2; + table_iterator = "pairs"; +}) -def_env.output = {}; +def_env.output = new_section("Configure admin console output"); function def_env.output:configure(opts) if type(opts) ~= "table" then opts = { preset = opts }; @@ -375,7 +374,57 @@ function def_env.output:configure(opts) self.session.serialize = serialization.new(opts); end -def_env.server = {}; +def_env.help = setmetatable({}, { + help = { + desc = "Show this help about available commands"; + commands = {}; + }; + __index = function (_, section_name) + return function (self) + local print = self.session.print; + local section_mt = getmetatable(def_env[section_name]); + local section_help = section_mt and section_mt.help; + + local c = 0; + + if section_help then + print("Help: "..section_name); + if section_help.desc then + print(section_help.desc); + end + print(("-"):rep(#(section_help.desc or section_name))); + print(""); + + if section_help.content then + print(section_help.content); + print(""); + end + + for command, command_help in it.sorted_pairs(section_help.commands or {}) do + c = c + 1; + local args = command_help.args:pluck("name"):concat(", "); + local desc = command_help.desc or command_help.module and ("Provided by mod_"..command_help.module) or ""; + print(("%s:%s(%s) - %s"):format(section_name, command, args, desc)); + end + elseif help_topics[section_name] then + local topic = help_topics[section_name]; + if type(topic.content) == "function" then + topic.content(self, print); + else + print(topic.content); + end + print(""); + return true, "Showing help topic '"..section_name.."'"; + else + print("Unknown topic: "..section_name); + end + print(""); + return true, ("%d command(s) listed"):format(c); + end; + end; +}); + +def_env.server = new_section("Uptime, version, shutting down, etc."); function def_env.server:insane_reload() prosody.unlock_globals(); @@ -384,10 +433,12 @@ function def_env.server:insane_reload() return true, "Server reloaded"; end +describe_command [[server:version() - Show the server's version number]] function def_env.server:version() return true, tostring(prosody.version or "unknown"); end +describe_command [[server:uptime() - Show how long the server has been running]] function def_env.server:uptime() local t = os.time()-prosody.start_time; local seconds = t%60; @@ -402,6 +453,7 @@ function def_env.server:uptime() minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time)); end +describe_command [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]] function def_env.server:shutdown(reason, code) prosody.shutdown(reason, code); return true, "Shutdown initiated"; @@ -411,6 +463,7 @@ local function human(kb) return format_number(kb*1024, "B", "b"); end +describe_command [[server:memory() - Show details about the server's memory usage]] function def_env.server:memory() if not has_pposix or not pposix.meminfo then return true, "Lua is using "..human(collectgarbage("count")); @@ -423,7 +476,7 @@ function def_env.server:memory() return true, "OK"; end -def_env.module = {}; +def_env.module = new_section("Commands to load/reload/unload modules/plugins"); local function get_hosts_set(hosts) if type(hosts) == "table" then @@ -469,6 +522,7 @@ local function get_hosts_with_module(hosts, module) return hosts_set; end +describe_command [[module:info(module, host) - Show information about a loaded module]] function def_env.module:info(name, hosts) if not name then return nil, "module name expected"; @@ -481,6 +535,16 @@ function def_env.module:info(name, hosts) local function item_name(item) return item.name; end + local function task_timefmt(t) + if not t then + return "no last run time" + elseif os.difftime(os.time(), t) < 86400 then + return os.date("last run today at %H:%M", t); + else + return os.date("last run %A at %H:%M", t); + end + end + local friendly_descriptions = { ["adhoc-provider"] = "Ad-hoc commands", ["auth-provider"] = "Authentication provider", @@ -498,12 +562,22 @@ function def_env.module:info(name, hosts) ["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, + ["net-provider"] = function(item) + local service_name = item.name; + local ports_list = {}; + for _, interface, port in portmanager.get_active_services():iter(service_name, nil, nil) do + table.insert(ports_list, "["..interface.."]:"..port); + end + if not ports_list[1] then + return service_name..": not listening on any ports"; + end + return service_name..": "..table.concat(ports_list, ", "); + end, ["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 + ["task"] = function (item) return string.format("%s (%s, %s)", item.name or item.id, item.when, task_timefmt(item.last)); end }; for host in hosts do @@ -533,21 +607,37 @@ function def_env.module:info(name, hosts) if mod.module.dependencies and next(mod.module.dependencies) ~= nil then print(" dependencies:"); for dep in pairs(mod.module.dependencies) do - print(" - mod_" .. dep); + -- Dependencies are per module instance, not per host, so dependencies + -- of/on global modules may list modules not actually loaded on the + -- current host. + if modulemanager.is_loaded(host, dep) then + print(" - mod_" .. dep); + end + end + end + if mod.module.reverse_dependencies and next(mod.module.reverse_dependencies) ~= nil then + print(" reverse dependencies:"); + for dep in pairs(mod.module.reverse_dependencies) do + if modulemanager.is_loaded(host, dep) then + print(" - mod_" .. dep); + end end end end return true; end -function def_env.module:load(name, hosts, config) +describe_command [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]] +function def_env.module:load(name, hosts) 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 + local configured_modules, component = modulemanager.get_modules_for_host(host); + if (not modulemanager.is_loaded(host, name)) then - mod, err = modulemanager.load(host, name, config); + mod, err = modulemanager.load(host, name); if not mod then ok = false; if err == "global-module-already-loaded" then @@ -560,6 +650,10 @@ function def_env.module:load(name, hosts, config) else count = count + 1; self.session.print("Loaded for "..mod.module.host); + + if not (configured_modules:contains(name) or name == component) then + self.session.print("Note: Module will not be loaded after restart unless enabled in configuration"); + end end end end @@ -567,12 +661,15 @@ function def_env.module:load(name, hosts, config) return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); end +describe_command [[module:unload(module, host) - The same, but just unloads the module from memory]] 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 + local configured_modules, component = modulemanager.get_modules_for_host(host); + if modulemanager.is_loaded(host, name) then ok, err = modulemanager.unload(host, name); if not ok then @@ -581,6 +678,10 @@ function def_env.module:unload(name, hosts) else count = count + 1; self.session.print("Unloaded from "..host); + + if configured_modules:contains(name) or name == component then + self.session.print("Note: Module will be loaded after restart unless disabled in configuration"); + end end end end @@ -593,6 +694,7 @@ local function _sort_hosts(a, b) else return a:gsub("[^.]+", string.reverse):reverse() < b:gsub("[^.]+", string.reverse):reverse(); end end +describe_command [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]] function def_env.module:reload(name, hosts) hosts = array.collect(get_hosts_with_module(hosts, name)):sort(_sort_hosts) @@ -616,6 +718,7 @@ function def_env.module:reload(name, hosts) return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); end +describe_command [[module:list(host) - List the modules loaded on the specified host]] function def_env.module:list(hosts) hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts); @@ -642,9 +745,10 @@ function def_env.module:list(hosts) end end -def_env.config = {}; +def_env.config = new_section("Reloading the configuration, etc."); + function def_env.config:load(filename, format) - local config_load = require "core.configmanager".load; + local config_load = require "prosody.core.configmanager".load; local ok, err = config_load(filename, format); if not ok then return false, err or "Unknown error loading config"; @@ -652,20 +756,30 @@ function def_env.config:load(filename, format) return true, "Config loaded"; end +describe_command [[config:get([host,] option) - Show the value of a config option.]] function def_env.config:get(host, key) if key == nil then host, key = "*", host; end - local config_get = require "core.configmanager".get + local config_get = require "prosody.core.configmanager".get return true, serialize_config(config_get(host, key)); end +describe_command [[config:set([host,] option, value) - Update the value of a config option without writing to the config file.]] +function def_env.config:set(host, key, value) + if host ~= "*" and not prosody.hosts[host] then + host, key, value = "*", host, key; + end + return require "prosody.core.configmanager".set(host, key, value); +end + +describe_command [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]] 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 = {}; +def_env.c2s = new_section("Commands to manage local client-to-server sessions"); local function get_jid(session) if session.username then @@ -702,6 +816,7 @@ local function show_c2s(callback) end); end +describe_command [[c2s:count() - Count sessions without listing them]] function def_env.c2s:count() local c2s = get_c2s(); return true, "Total: ".. #c2s .." clients"; @@ -719,7 +834,7 @@ available_columns = { jid = { title = "JID"; description = "Full JID of user session"; - width = 32; + width = "3p"; key = "full_jid"; mapper = function(full_jid, session) return full_jid or get_jid(session) end; }; @@ -727,7 +842,7 @@ available_columns = { title = "Host"; description = "Local hostname"; key = "host"; - width = 22; + width = "1p"; mapper = function(host, session) return host or get_s2s_hosts(session) or "?"; end; @@ -735,7 +850,7 @@ available_columns = { remote = { title = "Remote"; description = "Remote hostname"; - width = 22; + width = "1p"; mapper = function(_, session) return select(2, get_s2s_hosts(session)); end; @@ -743,7 +858,7 @@ available_columns = { port = { title = "Port"; description = "Server port used"; - width = 5; + width = #string.format("%d", 0xffff); -- max 16 bit unsigned integer align = "right"; key = "conn"; mapper = function(conn) @@ -752,10 +867,22 @@ available_columns = { end end; }; + created = { + title = "Connection Created"; + description = "Time when connection was created"; + width = #"YYYY MM DD HH:MM:SS"; + align = "right"; + key = "conn"; + mapper = function(conn) + if conn then + return os.date("%F %T", math.floor(conn.created)); + end + end; + }; dir = { title = "Dir"; description = "Direction of server-to-server connection"; - width = 3; + width = #"<->"; key = "direction"; mapper = function(dir, session) if session.incoming and session.outgoing then return "<->"; end @@ -763,12 +890,23 @@ available_columns = { if dir == "incoming" then return "<--"; end end; }; - id = { title = "Session ID"; description = "Internal session ID used in logging"; width = 20; key = "id" }; - type = { title = "Type"; description = "Session type"; width = #"c2s_unauthed"; key = "type" }; + id = { + title = "Session ID"; + description = "Internal session ID used in logging"; + -- Depends on log16(?) of pointers which may vary over runtime, so + some margin + width = math.max(#"c2s", #"s2sin", #"s2sout") + #(tostring({}):match("%x+$")) + 2; + key = "id"; + }; + type = { + title = "Type"; + description = "Session type"; + width = math.max(#"c2s_unauthed", #"s2sout_unauthed"); + key = "type"; + }; method = { title = "Method"; description = "Connection method"; - width = 10; + width = math.max(#"BOSH", #"WebSocket", #"TCP"); mapper = function(_, session) if session.bosh_version then return "BOSH"; @@ -782,15 +920,20 @@ available_columns = { ipv = { title = "IPv"; description = "Internet Protocol version (4 or 6)"; - width = 4; + width = #"IPvX"; key = "ip"; mapper = function(ip) if ip then return ip:find(":") and "IPv6" or "IPv4"; end end; }; - ip = { title = "IP address"; description = "IP address the session connected from"; width = 40; key = "ip" }; + ip = { + title = "IP address"; + description = "IP address the session connected from"; + width = module:get_option_boolean("use_ipv6", true) and #"ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" or #"198.051.100.255"; + key = "ip"; + }; status = { title = "Status"; description = "Presence status"; - width = 6; + width = math.max(#"online", #"chat"); key = "presence"; mapper = function(p) if not p then return ""; end @@ -801,24 +944,22 @@ available_columns = { title = "Security"; description = "TLS version or security status"; key = "conn"; - width = 8; + width = math.max(#"secure", #"TLSvX.Y"); 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(); + local tls_info = conn.ssl_info and conn:ssl_info(); return tls_info and tls_info.protocol or "secure"; end; }; encryption = { title = "Encryption"; description = "Encryption algorithm used (TLS cipher suite)"; - width = 30; + -- openssl ciphers 'ALL:COMPLEMENTOFALL' | tr : \\n | awk 'BEGIN {n=1} length() > n {n=length()} END {print(n)}' + width = #"ECDHE-ECDSA-CHACHA20-POLY1305"; key = "conn"; mapper = function(conn) - local sock = conn and conn:socket(); - local info = sock and sock.info and sock:info(); + local info = conn and conn.ssl_info and conn:ssl_info(); if info then return info.cipher end end; }; @@ -826,27 +967,36 @@ available_columns = { title = "Certificate"; description = "Validation status of certificate"; key = "cert_identity_status"; - width = 11; + width = math.max(#"Expired", #"Self-signed", #"Untrusted", #"Mismatched", #"Unknown"); mapper = function(cert_status, session) - if cert_status then return capitalize(cert_status); end - if session.cert_chain_status == "invalid" then + if cert_status == "invalid" then + -- non-nil cert_identity_status implies valid chain, which covers just + -- about every error condition except mismatched certificate names + return "Mismatched"; + elseif cert_status then + -- basically only "valid" + return capitalize(cert_status); + end + -- no certificate status, + if type(session.cert_chain_errors) == "table" 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 + -- Some other cert issue, or something up the chain + -- TODO borrow more logic from mod_s2s/friendly_cert_error() return "Untrusted"; - elseif session.cert_identity_status == "invalid" then - return "Mismatched"; end + -- TODO cert_chain_errors can be a string, handle that return "Unknown"; end; }; sni = { title = "SNI"; description = "Hostname requested in TLS"; - width = 22; + width = "1p"; -- same as host, remote etc mapper = function(_, session) if not session.conn then return end local sock = session.conn:socket(); @@ -856,7 +1006,7 @@ available_columns = { alpn = { title = "ALPN"; description = "Protocol requested in TLS"; - width = 11; + width = math.max(#"http/1.1", #"xmpp-client", #"xmpp-server"); mapper = function(_, session) if not session.conn then return end local sock = session.conn:socket(); @@ -867,7 +1017,8 @@ available_columns = { title = "SM"; description = "Stream Management (XEP-0198) status"; key = "smacks"; - width = 11; + -- FIXME shorter synonym for hibernating + width = math.max(#"yes", #"no", #"hibernating"); mapper = function(smacks_xmlns, session) if not smacks_xmlns then return "no"; end if session.hibernating then return "hibernating"; end @@ -901,7 +1052,7 @@ available_columns = { title = "Dialback"; description = "Legacy server verification"; key = "dialback_key"; - width = 13; + width = math.max(#"Not used", #"Not initiated", #"Initiated", #"Completed"); mapper = function (dialback_key, session) if not dialback_key then if session.type == "s2sin" or session.type == "s2sout" then @@ -915,6 +1066,16 @@ available_columns = { end end }; + role = { + title = "Role"; + description = "Session role with 'prosody:' prefix removed"; + width = "1p"; + key = "role"; + mapper = function(role) + local name = role and role.name; + return name and name:match"^prosody:(%w+)" or name; + end; + } }; local function get_colspec(colspec, default) @@ -922,7 +1083,7 @@ local function get_colspec(colspec, default) 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 }; + columns[i] = available_columns[col] or { title = capitalize(col); width = "1p"; key = col }; elseif type(col) ~= "table" then return false, ("argument %d: expected string|table but got %s"):format(i, type(col)); else @@ -933,14 +1094,15 @@ local function get_colspec(colspec, default) return columns; end +describe_command [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]] 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 columns = get_colspec(colspec, { "id"; "jid"; "role"; "ipv"; "status"; "secure"; "smacks"; "csi" }); + local row = format_table(columns, self.session.width); local function match(session) local jid = get_jid(session) - return (not match_jid) or jid_compare(jid, match_jid); + return (not match_jid) or match_jid == "*" or jid_compare(jid, match_jid); end local group_by_host = true; @@ -973,6 +1135,7 @@ function def_env.c2s:show(match_jid, colspec) return true, ("%d c2s sessions shown"):format(total_count); end +describe_command [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]] function def_env.c2s:show_tls(match_jid) return self:show(match_jid, { "jid"; "id"; "secure"; "encryption" }); end @@ -986,6 +1149,7 @@ local function build_reason(text, condition) end end +describe_command [[c2s:close(jid) - Close all sessions for the specified JID]] function def_env.c2s:close(match_jid, text, condition) local count = 0; show_c2s(function (jid, session) @@ -997,6 +1161,7 @@ function def_env.c2s:close(match_jid, text, condition) return true, "Total: "..count.." sessions closed"; end +describe_command [[c2s:closeall() - Close all active c2s connections ]] function def_env.c2s:closeall(text, condition) local count = 0; --luacheck: ignore 212/jid @@ -1008,7 +1173,8 @@ function def_env.c2s:closeall(text, condition) end -def_env.s2s = {}; +def_env.s2s = new_section("Commands to manage sessions between this server and others"); + local function _sort_s2s(a, b) local a_local, a_remote = get_s2s_hosts(a); local b_local, b_remote = get_s2s_hosts(b); @@ -1016,14 +1182,31 @@ local function _sort_s2s(a, b) return _sort_hosts(a_local or "", b_local or ""); end +local function match_wildcard(match_jid, jid) + -- host == host or (host) == *.(host) or sub(.host) == *(.host) + return jid == match_jid or jid == match_jid:sub(3) or jid:sub(-#match_jid + 1) == match_jid:sub(2); +end + +local function match_s2s_jid(session, match_jid) + local host, remote = get_s2s_hosts(session); + if not match_jid or match_jid == "*" then + return true; + elseif host == match_jid or remote == match_jid then + return true; + elseif match_jid:sub(1, 2) == "*." then + return match_wildcard(match_jid, host) or match_wildcard(match_jid, remote); + end + return false; +end + +describe_command [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]] 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 row = format_table(columns, self.session.width); local function match(session) - local host, remote = get_s2s_hosts(session); - return not match_jid or host == match_jid or remote == match_jid; + return match_s2s_jid(session, match_jid); end local group_by_host = true; @@ -1057,6 +1240,7 @@ function def_env.s2s:show(match_jid, colspec) return true, ("%d s2s connections shown"):format(total_count); end +describe_command [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]] function def_env.s2s:show_tls(match_jid) return self:show(match_jid, { "id"; "host"; "dir"; "remote"; "secure"; "encryption"; "cert" }); end @@ -1090,7 +1274,7 @@ 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; + /function(session) return match_s2s_jid(session, domain) and session or nil; end; local cert_set = {}; for session in domain_sessions do local conn = session.conn; @@ -1179,6 +1363,7 @@ function def_env.s2s:showcert(domain) .." presented by "..domain.."."); end +describe_command [[s2s:close(from, to) - Close a connection from one domain to another]] function def_env.s2s:close(from, to, text, condition) local print, count = self.session.print, 0; local s2s_sessions = module:shared"/*/s2s/sessions"; @@ -1193,22 +1378,22 @@ function def_env.s2s:close(from, to, text, condition) 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 + local id = session.id or (session.type .. tostring(session):match("[a-f0-9]+$")); + if (match_id and match_id == id) or ((from and match_wildcard(from, session.to_host)) or (to and match_wildcard(to, session.to_host))) 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 ; + count = count + 1; end end return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end +describe_command [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]] 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 + if not host or host == "*" or match_s2s_jid(session, host) then session:close(build_reason(text, condition)); count = count + 1; end @@ -1217,37 +1402,42 @@ function def_env.s2s:closeall(host, text, condition) else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end end -def_env.host = {}; def_env.hosts = def_env.host; +def_env.host = new_section("Commands to activate, deactivate and list virtual hosts"); +describe_command [[host:activate(hostname) - Activates the specified host]] function def_env.host:activate(hostname, config) return hostmanager.activate(hostname, config); end + +describe_command [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]] function def_env.host:deactivate(hostname, reason) return hostmanager.deactivate(hostname, reason); end +describe_command [[host:list() - List the currently-activated hosts]] function def_env.host:list() local print = self.session.print; local i = 0; - local type; + local host_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 + host_type = host_session.type; + if host_type == "local" then print(host); else - type = module:context(host):get_option_string("component_module", type); - if type ~= "component" then - type = type .. " component"; + host_type = module:context(host):get_option_string("component_module", host_type); + if host_type ~= "component" then + host_type = host_type .. " component"; end - print(("%s (%s)"):format(host, type)); + print(("%s (%s)"):format(host, host_type)); end end return true, i.." hosts"; end -def_env.port = {}; +def_env.port = new_section("Commands to manage ports the server is listening on"); +describe_command [[port:list() - Lists all network ports prosody currently listens on]] function def_env.port:list() local print = self.session.print; local services = portmanager.get_active_services().data; @@ -1266,6 +1456,7 @@ function def_env.port:list() return true, n_services.." services listening on "..n_ports.." ports"; end +describe_command [[port:close(port, interface) - Close a port]] function def_env.port:close(close_port, close_interface) close_port = assert(tonumber(close_port), "Invalid port number"); local n_closed = 0; @@ -1288,7 +1479,7 @@ function def_env.port:close(close_port, close_interface) return true, "Closed "..n_closed.." ports"; end -def_env.muc = {}; +def_env.muc = new_section("Commands to create, list and manage chat rooms"); local console_room_mt = { __index = function (self, k) return self.room[k]; end; @@ -1307,6 +1498,21 @@ local function check_muc(jid) return room_name, host; end +local function get_muc(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 room_obj; +end + +local muc_util = module:require"muc/util"; + +describe_command [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]] function def_env.muc:create(room_jid, config) local room_name, host = check_muc(room_jid); if not room_name then @@ -1318,18 +1524,16 @@ function def_env.muc:create(room_jid, config) return prosody.hosts[host].modules.muc.create_room(room_jid, config); end +describe_command [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]] 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); + local room_obj, err = get_muc(room_jid); if not room_obj then - return nil, "No such room: "..room_jid; + return room_obj, err; end return setmetatable({ room = room_obj }, console_room_mt); end +describe_command [[muc:list(host) - List rooms on the specified MUC component]] function def_env.muc:list(host) local host_session = prosody.hosts[host]; if not host_session or not host_session.modules.muc then @@ -1344,36 +1548,160 @@ function def_env.muc:list(host) return true, c.." rooms"; end -local um = require"core.usermanager"; +describe_command [[muc:occupants(roomjid, filter) - List room occupants, optionally filtered on substring or role]] +function def_env.muc:occupants(room_jid, filter) + local room_obj, err = get_muc(room_jid); + if not room_obj then + return room_obj, err; + end + + local print = self.session.print; + local row = format_table({ + { title = "Role"; width = 12; key = "role" }; -- longest role name + { title = "JID"; width = "75%"; key = "bare_jid" }; + { title = "Nickname"; width = "25%"; key = "nick"; mapper = jid_resource }; + }, self.session.width); + local occupants = array.collect(iterators.select(2, room_obj:each_occupant())); + local total = #occupants; + if filter then + occupants:filter(function(occupant) + return occupant.role == filter or jid_resource(occupant.nick):find(filter, 1, true); + end); + end + local displayed = #occupants; + occupants:sort(function(a, b) + if a.role ~= b.role then + return muc_util.valid_roles[a.role] > muc_util.valid_roles[b.role]; + else + return a.bare_jid < b.bare_jid; + end + end); + + if displayed == 0 then + return true, ("%d out of %d occupant%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end + + print(row()); + for _, occupant in ipairs(occupants) do + print(row(occupant)); + end + + if total == displayed then + return true, ("%d occupant%s listed"):format(total, total ~= 1 and "s" or "") + else + return true, ("%d out of %d occupant%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end +end + +describe_command [[muc:affiliations(roomjid, filter) - List affiliated members of the room, optionally filtered on substring or affiliation]] +function def_env.muc:affiliations(room_jid, filter) + local room_obj, err = get_muc(room_jid); + if not room_obj then + return room_obj, err; + end + + local print = self.session.print; + local row = format_table({ + { title = "Affiliation"; width = 12 }; -- longest affiliation name + { title = "JID"; width = "75%" }; + { title = "Nickname"; width = "25%"; key = "reserved_nickname" }; + }, self.session.width); + local affiliated = array(); + for affiliated_jid, affiliation, affiliation_data in room_obj:each_affiliation() do + affiliated:push(setmetatable({ affiliation; affiliated_jid }, { __index = affiliation_data })); + end + + local total = #affiliated; + if filter then + affiliated:filter(function(affiliation) + return filter == affiliation[1] or affiliation[2]:find(filter, 1, true); + end); + end + local displayed = #affiliated; + local aff_ranking = muc_util.valid_affiliations; + affiliated:sort(function(a, b) + if a[1] ~= b[1] then + return aff_ranking[a[1]] > aff_ranking[b[1]]; + else + return a[2] < b[2]; + end + end); + + if displayed == 0 then + return true, ("%d out of %d affiliations%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end + + print(row()); + for _, affiliation in ipairs(affiliated) do + print(row(affiliation)); + end + -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; + if total == displayed then + return true, ("%d affiliation%s listed"):format(total, total ~= 1 and "s" or "") + else + return true, ("%d out of %d affiliation%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end end -def_env.user = {}; -function def_env.user:create(jid, password, roles) +local um = require"prosody.core.usermanager"; + +def_env.user = new_section("Commands to create and delete users, and change their passwords"); + +describe_command [[user:create(jid, password, role) - Create the specified user account]] +function def_env.user:create(jid, password, role) 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 not role then + role = module:get_option_string("default_provisioned_role", "prosody:member"); + end + + local ok, err = um.create_user_with_role(username, password, host, role); + if not ok then + return nil, "Could not create user: "..err; + end + + return true, ("Created %s with role '%s'"):format(jid, role); +end + +describe_command [[user:disable(jid) - Disable the specified user account, preventing login]] +function def_env.user:disable(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.disable_user(username, 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"; + return true, "User disabled"; else - return nil, "Could not create user: "..err; + return nil, "Could not disable user: "..err; end end +describe_command [[user:enable(jid) - Enable the specified user account, restoring login access]] +function def_env.user:enable(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.enable_user(username, host); + if ok then + return true, "User enabled"; + else + return nil, "Could not enable user: "..err; + end +end + +describe_command [[user:delete(jid) - Permanently remove the specified user account]] function def_env.user:delete(jid) local username, host = jid_split(jid); if not prosody.hosts[host] then @@ -1389,6 +1717,7 @@ function def_env.user:delete(jid) end end +describe_command [[user:password(jid, password) - Set the password for the specified user account]] function def_env.user:password(jid, password) local username, host = jid_split(jid); if not prosody.hosts[host] then @@ -1404,43 +1733,71 @@ function def_env.user:password(jid, password) end end -function def_env.user:roles(jid, host, new_roles) - if new_roles or type(host) == "table" then - return nil, "Use user:setroles(jid, host, roles) to change user roles"; - end +describe_command [[user:roles(jid, host) - Show current roles for an user]] +function def_env.user:role(jid, host) + local print = self.session.print; local username, userhost = jid_split(jid); if host == nil then host = userhost; end - if host ~= "*" and not prosody.hosts[host] then + if 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 - local roles = um.get_roles(jid, host); - if not roles then return true, "No roles"; end - local count = 0; - local print = self.session.print; - for role in pairs(roles) do + + local primary_role = um.get_user_role(username, host); + local secondary_roles = um.get_user_secondary_roles(username, host); + + print(primary_role and primary_role.name or "<none>"); + + local count = primary_role and 1 or 0; + for role_name in pairs(secondary_roles or {}) do count = count + 1; - print(role); + print(role_name.." (secondary)"); end + return true, count == 1 and "1 role" or count.." roles"; end -def_env.user.showroles = def_env.user.roles; -- COMPAT +def_env.user.roles = def_env.user.role; --- user:roles("someone@example.com", "example.com", {"prosody:admin"}) --- user:roles("someone@example.com", {"prosody:admin"}) -function def_env.user:setroles(jid, host, new_roles) +describe_command [[user:setrole(jid, host, role) - Set primary role of a user (see 'help roles')]] +-- user:setrole("someone@example.com", "example.com", "prosody:admin") +-- user:setrole("someone@example.com", "prosody:admin") +function def_env.user:setrole(jid, host, new_role) 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 + if new_role == nil then host, new_role = userhost, host; end + if 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)); + return um.set_user_role(username, host, new_role); end +describe_command [[user:addrole(jid, host, role) - Add a secondary role to a user]] +function def_env.user:addrole(jid, host, new_role) + local username, userhost = jid_split(jid); + if new_role == nil then host, new_role = userhost, host; end + if 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 + return um.add_user_secondary_role(username, host, new_role); +end + +describe_command [[user:delrole(jid, host, role) - Remove a secondary role from a user]] +function def_env.user:delrole(jid, host, role_name) + local username, userhost = jid_split(jid); + if role_name == nil then host, role_name = userhost, host; end + if 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 + return um.remove_user_secondary_role(username, host, role_name); +end + +describe_command [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]] -- TODO switch to table view, include roles function def_env.user:list(host, pat) if not host then @@ -1460,9 +1817,10 @@ function def_env.user:list(host, pat) return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users"; end -def_env.xmpp = {}; +def_env.xmpp = new_section("Commands for sending XMPP stanzas"); -local new_id = require "util.id".medium; +describe_command [[xmpp:ping(localhost, remotehost) - Sends a ping to a remote XMPP server and reports the response]] +local new_id = require "prosody.util.id".medium; function def_env.xmpp:ping(localhost, remotehost, timeout) localhost = select(2, jid_split(localhost)); remotehost = select(2, jid_split(remotehost)); @@ -1509,12 +1867,12 @@ function def_env.xmpp:ping(localhost, remotehost, timeout) 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); + return ("pong from %s on %s in %gs"):format(pong.stanza.attr.from, pong.origin.id, time.now() - time_start); end); end -def_env.dns = {}; -local adns = require"net.adns"; +def_env.dns = new_section("Commands to manage and inspect the internal DNS resolver"); +local adns = require"prosody.net.adns"; local function get_resolver(session) local resolver = session.dns_resolver; @@ -1525,43 +1883,54 @@ local function get_resolver(session) return resolver; end +describe_command [[dns:lookup(name, type, class) - Do a DNS lookup]] function def_env.dns:lookup(name, typ, class) local resolver = get_resolver(self.session); return resolver:lookup_promise(name, typ, class) end +describe_command [[dns:addnameserver(nameserver) - Add a nameserver to the list]] function def_env.dns:addnameserver(...) local resolver = get_resolver(self.session); resolver._resolver:addnameserver(...) return true end +describe_command [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]] function def_env.dns:setnameserver(...) local resolver = get_resolver(self.session); resolver._resolver:setnameserver(...) return true end +describe_command [[dns:purge() - Clear the DNS cache]] function def_env.dns:purge() local resolver = get_resolver(self.session); resolver._resolver:purge() return true end +describe_command [[dns:cache() - Show cached records]] function def_env.dns:cache() local resolver = get_resolver(self.session); return true, "Cache:\n"..tostring(resolver._resolver.cache) end -def_env.http = {}; +def_env.http = new_section("Commands to inspect HTTP services"); +describe_command [[http:list(hosts) - Show HTTP endpoints]] 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); + local output_simple = format_table({ + { title = "Module"; width = "1p" }; + { title = "External URL"; width = "6p" }; + }, self.session.width); + local output_split = format_table({ + { title = "Module"; width = "1p" }; + { title = "External URL"; width = "3p" }; + { title = "Internal URL"; width = "3p" }; + }, self.session.width); for _, host in ipairs(hosts) do local http_apps = modulemanager.get_items("http-provider", host); @@ -1572,12 +1941,14 @@ function def_env.http:list(hosts) else print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":")); end - print(output()); + print(output_split()); for _, provider in ipairs(http_apps) do local mod = provider._provided_by; - local url = module:context(host):http_url(provider.name, provider.default_path); + local external = module:context(host):http_url(provider.name, provider.default_path); + local internal = module:context(host):http_url(provider.name, provider.default_path, "internal"); + if external==internal then internal="" end mod = mod and "mod_"..mod or "" - print(output{mod, url}); + print((internal=="" and output_simple or output_split){mod, external, internal}); end print(""); end @@ -1592,18 +1963,83 @@ function def_env.http:list(hosts) return true; end -def_env.debug = {}; +def_env.watch = new_section("Commands for watching live logs from the server"); + +describe_command [[watch:log() - Follow debug logs]] +function def_env.watch:log() + local writing = false; + local sink = logger.add_simple_sink(function (source, level, message) + if writing then return; end + writing = true; + self.session.print(source, level, message); + writing = false; + end); + + while self.session.is_connected() do + async.sleep(3); + end + if not logger.remove_sink(sink) then + module:log("warn", "Unable to remove watch:log() sink"); + end +end + +describe_command [[watch:stanzas(target, filter) - Watch live stanzas matching the specified target and filter]] +local stanza_watchers = module:require("mod_debug_stanzas/watcher"); +function def_env.watch:stanzas(target_spec, filter_spec) + local function handler(event_type, stanza, session) + if stanza then + if event_type == "sent" then + self.session.print(("\n<!-- sent to %s -->"):format(session.id)); + elseif event_type == "received" then + self.session.print(("\n<!-- received from %s -->"):format(session.id)); + else + self.session.print(("\n<!-- %s (%s) -->"):format(event_type, session.id)); + end + self.session.print(stanza); + elseif session then + self.session.print("\n<!-- session "..session.id.." "..event_type.." -->"); + elseif event_type then + self.session.print("\n<!-- "..event_type.." -->"); + end + end + stanza_watchers.add({ + target_spec = { + jid = target_spec; + }; + filter_spec = filter_spec and { + with_jid = filter_spec; + }; + }, handler); + + while self.session.is_connected() do + async.sleep(3); + end + + stanza_watchers.remove(handler); +end + +def_env.debug = new_section("Commands for debugging the server"); + +describe_command [[debug:logevents(host) - Enable logging of fired events on host]] function def_env.debug:logevents(host) - helpers.log_host_events(host); + if host == "*" then + helpers.log_events(prosody.events); + elseif host == "http" then + helpers.log_events(require "prosody.net.http.server"._events); + return true + else + helpers.log_host_events(host); + end return true; end +describe_command [[debug:events(host, event) - Show registered event handlers]] 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; + events_obj = require "prosody.net.http.server"._events; elseif not prosody.hosts[host] then return false, "Unknown host: "..host; else @@ -1615,9 +2051,10 @@ function def_env.debug:events(host, event) return true, helpers.show_events(events_obj, event); end +describe_command [[debug:timers() - Show information about scheduled timers]] function def_env.debug:timers() local print = self.session.print; - local add_task = require"util.timer".add_task; + local add_task = require"prosody.util.timer".add_task; local h, params = add_task.h, add_task.params; local function normalize_time(t) return t; @@ -1671,10 +2108,70 @@ function def_env.debug:timers() return true; end --- COMPAT: debug:timers() was timer:info() for some time in trunk -def_env.timer = { info = def_env.debug.timers }; +describe_command [[debug:async() - Show information about pending asynchronous tasks]] +function def_env.debug:async(runner_id) + local print = self.session.print; + local time_now = time.now(); + + if runner_id then + for runner, since in pairs(async.waiting_runners) do + if runner.id == runner_id then + print("ID ", runner.id); + local f = runner.func; + if f == async.default_runner_func then + print("Function ", tostring(runner.current_item).." (from work queue)"); + else + print("Function ", tostring(f)); + if st.is_stanza(runner.current_item) then + print("Stanza:") + print("\t"..runner.current_item:indent(2):pretty_print()); + else + print("Work item", self.session.serialize(runner.current_item, "debug")); + end + end + + print("Coroutine ", tostring(runner.thread).." ("..coroutine.status(runner.thread)..")"); + print("Since ", since); + print("Status ", ("%s since %s (%0.2f seconds ago)"):format(runner.state, os.date("%Y-%m-%d %R:%S", math.floor(since)), time_now-since)); + print(""); + print(debug.traceback(runner.thread)); + return true, "Runner is "..runner.state; + end + end + return nil, "Runner not found or is currently idle"; + end -def_env.stats = {}; + local row = format_table({ + { title = "ID"; width = 12 }; + { title = "Function"; width = "10p" }; + { title = "Status"; width = "16" }; + { title = "Location"; width = "10p" }; + }, self.session.width); + print(row()) + + local c = 0; + for runner, since in pairs(async.waiting_runners) do + c = c + 1; + local f = runner.func; + if f == async.default_runner_func then + f = runner.current_item; + end + -- We want to fetch the location in the code that the runner yielded from, + -- excluding util.async's wrapper code. A level of `2` assumes that we + -- yielded directly from a function in util.async. This is *currently* true + -- of all util.async yields, but it's fragile. + local location = debug.getinfo(runner.thread, 2); + print(row { + runner.id; + tostring(f); + ("%s (%0.2fs)"):format(runner.state, time_now - since); + location.short_src..(location.currentline and ":"..location.currentline or ""); + }); + end + return true, ("%d runners pending"):format(c); +end + +def_env.stats = new_section("Commands to show internal statistics"); local short_units = { seconds = "s", @@ -1913,8 +2410,10 @@ local function new_stats_context(self) return setmetatable({ session = self.session, stats = true, now = time.now() }, stats_mt); end +describe_command [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern.]] +-- Undocumented currently, you can append :histogram() or :cfgraph() to stats:show() for rendered graphs. function def_env.stats:show(name_filter) - local statsman = require "core.statsmanager" + local statsman = require "prosody.core.statsmanager" local collect = statsman.collect if collect then -- force collection if in manual mode @@ -1934,6 +2433,176 @@ function def_env.stats:show(name_filter) return displayed_stats; end +local command_metadata_schema = { + type = "object"; + properties = { + section = { type = "string" }; + section_desc = { type = "string" }; + + name = { type = "string" }; + desc = { type = "string" }; + help = { type = "string" }; + args = { + type = "array"; + items = { + type = "object"; + properties = { + name = { type = "string", required = true }; + type = { type = "string", required = false }; + }; + }; + }; + }; + + required = { "name", "section", "desc", "args" }; +}; + +-- host_commands[section..":"..name][host] = handler +-- host_commands[section..":"..name][false] = metadata +local host_commands = {}; + +local function new_item_handlers(command_host) + local function on_command_added(event) + local command = event.item; + local mod_name = command._provided_by and ("mod_"..command._provided_by) or "<unknown module>"; + if not schema.validate(command_metadata_schema, command) or type(command.handler) ~= "function" then + module:log("warn", "Ignoring command added by %s: missing or invalid data", mod_name); + return; + end + + local handler = command.handler; + + if command_host then + if type(command.host_selector) ~= "string" then + module:log("warn", "Ignoring command %s:%s() added by %s - missing/invalid host_selector", command.section, command.name, mod_name); + return; + end + local qualified_name = command.section..":"..command.name; + local host_command_info = host_commands[qualified_name]; + if not host_command_info then + local selector_index; + for i, arg in ipairs(command.args) do + if arg.name == command.host_selector then + selector_index = i + 1; -- +1 to account for 'self' + break; + end + end + if not selector_index then + module:log("warn", "Command %s() host selector argument '%s' not found - not registering", qualified_name, command.host_selector); + return; + end + host_command_info = { + [false] = { + host_selector = command.host_selector; + handler = function (...) + local selected_host = select(2, jid_split((select(selector_index, ...)))); + if type(selected_host) ~= "string" then + return nil, "Invalid or missing argument '"..command.host_selector.."'"; + end + if not prosody.hosts[selected_host] then + return nil, "Unknown host: "..selected_host; + end + local host_handler = host_commands[qualified_name][selected_host]; + if not host_handler then + return nil, "This command is not available on "..selected_host; + end + return host_handler(...); + end; + }; + }; + host_commands[qualified_name] = host_command_info; + end + if host_command_info[command_host] then + module:log("warn", "Command %s() is already registered - overwriting with %s", qualified_name, mod_name); + end + host_command_info[command_host] = handler; + end + + local section_t = def_env[command.section]; + if not section_t then + section_t = {}; + def_env[command.section] = section_t; + end + + if command_host then + section_t[command.name] = host_commands[command.section..":"..command.name][false].handler; + else + section_t[command.name] = command.handler; + end + + local section_mt = getmetatable(section_t); + if not section_mt then + section_mt = {}; + setmetatable(section_t, section_mt); + end + local section_help = section_mt.help; + if not section_help then + section_help = { + desc = command.section_desc; + commands = {}; + }; + section_mt.help = section_help; + end + + section_help.commands[command.name] = { + desc = command.desc; + full = command.help; + args = array(command.args); + module = command._provided_by; + }; + + module:log("debug", "Shell command added by mod_%s: %s:%s()", mod_name, command.section, command.name); + end + + local function on_command_removed(event) + local command = event.item; + + local handler = event.item.handler; + if type(handler) ~= "function" or not schema.validate(command_metadata_schema, command) then + return; + end + + local section_t = def_env[command.section]; + if not section_t or section_t[command.name] ~= handler then + return; + end + + section_t[command.name] = nil; + if next(section_t) == nil then -- Delete section if empty + def_env[command.section] = nil; + end + + if command_host then + local host_command_info = host_commands[command.section..":"..command.name]; + if host_command_info then + -- Remove our host handler + host_command_info[command_host] = nil; + -- Clean up entire command entry if there are no per-host handlers left + local any_hosts = false; + for k in pairs(host_command_info) do + if k then -- metadata is false, ignore it + any_hosts = true; + break; + end + end + if not any_hosts then + host_commands[command.section..":"..command.name] = nil; + end + end + end + end + return on_command_added, on_command_removed; +end + +module:handle_items("shell-command", new_item_handlers()); + +function module.add_host(host_module) + host_module:handle_items("shell-command", new_item_handlers(host_module.host)); +end + +function module.unload() + stanza_watchers.cleanup(); +end ------------- diff --git a/plugins/mod_admin_socket.lua b/plugins/mod_admin_socket.lua index 157e746c..ad6aa5d7 100644 --- a/plugins/mod_admin_socket.lua +++ b/plugins/mod_admin_socket.lua @@ -8,7 +8,7 @@ if have_unix and type(unix) == "function" then -- constructor was exported instead of a module table. Due to the lack of a -- proper release of LuaSocket, distros have settled on shipping either the -- last RC tag or some commit since then. - -- Here we accomodate both variants. + -- Here we accommodate both variants. unix = { stream = unix }; end if not have_unix or type(unix) ~= "table" then @@ -16,10 +16,10 @@ if not have_unix or type(unix) ~= "table" then return; end -local server = require "net.server"; +local server = require "prosody.net.server"; -local adminstream = require "util.adminstream"; -local st = require "util.stanza"; +local adminstream = require "prosody.util.adminstream"; +local st = require "prosody.util.stanza"; local socket_path = module:get_option_path("admin_socket", "prosody.sock", "data"); diff --git a/plugins/mod_admin_telnet.lua b/plugins/mod_admin_telnet.lua index 15220ec9..e93f61a9 100644 --- a/plugins/mod_admin_telnet.lua +++ b/plugins/mod_admin_telnet.lua @@ -12,8 +12,8 @@ module:depends("admin_shell"); local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" }; -local async = require "util.async"; -local st = require "util.stanza"; +local async = require "prosody.util.async"; +local st = require "prosody.util.stanza"; local def_env = module:shared("admin_shell/env"); local default_env_mt = { __index = def_env }; diff --git a/plugins/mod_announce.lua b/plugins/mod_announce.lua index c742ebb8..f54d2db9 100644 --- a/plugins/mod_announce.lua +++ b/plugins/mod_announce.lua @@ -6,12 +6,15 @@ -- COPYING file in the source package for more information. -- -local st, jid = require "util.stanza", require "util.jid"; +local usermanager = require "prosody.core.usermanager"; +local id = require "prosody.util.id"; +local jid = require "prosody.util.jid"; +local st = require "prosody.util.stanza"; local hosts = prosody.hosts; -local is_admin = require "core.usermanager".is_admin; function send_to_online(message, host) + host = host or module.host; local sessions; if host then sessions = { [host] = hosts[host] }; @@ -34,6 +37,29 @@ function send_to_online(message, host) return c; end +function send_to_all(message, host) + host = host or module.host; + local c = 0; + for username in usermanager.users(host) do + message.attr.to = username.."@"..host; + module:send(st.clone(message)); + c = c + 1; + end + return c; +end + +function send_to_role(message, role, host) + host = host or module.host; + local c = 0; + for _, recipient_jid in ipairs(usermanager.get_jids_with_role(role, host)) do + message.attr.to = recipient_jid; + module:send(st.clone(message)); + c = c + 1; + end + return c; +end + +module:default_permission("prosody:admin", ":send-announcement"); -- Old <message>-based jabberd-style announcement sending function handle_announcement(event) @@ -45,8 +71,8 @@ function handle_announcement(event) return; -- Not an announcement end - if not is_admin(stanza.attr.from, host) then - -- Not an admin? Not allowed! + if not module:may(":send-announcement", event) then + -- Not allowed! module:log("warn", "Non-admin '%s' tried to send server announcement", stanza.attr.from); return; end @@ -63,7 +89,7 @@ end module:hook("message/host", handle_announcement); -- Ad-hoc command (XEP-0133) -local dataforms_new = require "util.dataforms".new; +local dataforms_new = require "prosody.util.dataforms".new; local announce_layout = dataforms_new{ title = "Making an Announcement"; instructions = "Fill out this form to make an announcement to all\nactive users of this service."; @@ -82,8 +108,10 @@ function announce_handler(_, data, state) local fields = announce_layout:data(data.form); module:log("info", "Sending server announcement to all online users"); - local message = st.message({type = "headline"}, fields.announcement):up() - :tag("subject"):text(fields.subject or "Announcement"); + local message = st.message({type = "headline"}, fields.announcement):up(); + if fields.subject and fields.subject ~= "" then + message:text_tag("subject", fields.subject); + end local count = send_to_online(message, data.to); @@ -99,3 +127,57 @@ local adhoc_new = module:require "adhoc".new; local announce_desc = adhoc_new("Send Announcement to Online Users", "http://jabber.org/protocol/admin#announce", announce_handler, "admin"); module:provides("adhoc", announce_desc); +module:add_item("shell-command", { + section = "announce"; + section_desc = "Broadcast announcements to users"; + name = "all"; + desc = "Send announcement to all users on the host"; + args = { + { name = "host", type = "string" }; + { name = "text", type = "string" }; + }; + host_selector = "host"; + handler = function(self, host, text) --luacheck: ignore 212/self + local msg = st.message({ from = host, id = id.short() }) + :text_tag("body", text); + local count = send_to_all(msg, host); + return true, ("Announcement sent to %d users"):format(count); + end; +}); + +module:add_item("shell-command", { + section = "announce"; + section_desc = "Broadcast announcements to users"; + name = "online"; + desc = "Send announcement to all online users on the host"; + args = { + { name = "host", type = "string" }; + { name = "text", type = "string" }; + }; + host_selector = "host"; + handler = function(self, host, text) --luacheck: ignore 212/self + local msg = st.message({ from = host, id = id.short(), type = "headline" }) + :text_tag("body", text); + local count = send_to_online(msg, host); + return true, ("Announcement sent to %d users"):format(count); + end; +}); + +module:add_item("shell-command", { + section = "announce"; + section_desc = "Broadcast announcements to users"; + name = "role"; + desc = "Send announcement to users with a specific role on the host"; + args = { + { name = "host", type = "string" }; + { name = "role", type = "string" }; + { name = "text", type = "string" }; + }; + host_selector = "host"; + handler = function(self, host, role, text) --luacheck: ignore 212/self + local msg = st.message({ from = host, id = id.short() }) + :text_tag("body", text); + local count = send_to_role(msg, role, host); + return true, ("Announcement sent to %d users"):format(count); + end; +}); diff --git a/plugins/mod_auth_anonymous.lua b/plugins/mod_auth_anonymous.lua index 90646e71..21373698 100644 --- a/plugins/mod_auth_anonymous.lua +++ b/plugins/mod_auth_anonymous.lua @@ -7,8 +7,8 @@ -- -- luacheck: ignore 212 -local new_sasl = require "util.sasl".new; -local datamanager = require "util.datamanager"; +local new_sasl = require "prosody.util.sasl".new; +local datamanager = require "prosody.util.datamanager"; local hosts = prosody.hosts; local allow_storage = module:get_option_boolean("allow_anonymous_storage", false); diff --git a/plugins/mod_auth_insecure.lua b/plugins/mod_auth_insecure.lua index dc5ee616..133c3292 100644 --- a/plugins/mod_auth_insecure.lua +++ b/plugins/mod_auth_insecure.lua @@ -7,9 +7,9 @@ -- -- luacheck: ignore 212 -local datamanager = require "util.datamanager"; -local new_sasl = require "util.sasl".new; -local saslprep = require "util.encodings".stringprep.saslprep; +local datamanager = require "prosody.util.datamanager"; +local new_sasl = require "prosody.util.sasl".new; +local saslprep = require "prosody.util.encodings".stringprep.saslprep; local host = module.host; local provider = { name = "insecure" }; @@ -27,6 +27,7 @@ function provider.set_password(username, password) return nil, "Password fails SASLprep."; end if account then + account.updated = os.time(); account.password = password; return datamanager.store(username, host, "accounts", account); end @@ -38,7 +39,8 @@ function provider.user_exists(username) end function provider.create_user(username, password) - return datamanager.store(username, host, "accounts", {password = password}); + local now = os.time(); + return datamanager.store(username, host, "accounts", { created = now; updated = now; password = password }); end function provider.delete_user(username) diff --git a/plugins/mod_auth_internal_hashed.lua b/plugins/mod_auth_internal_hashed.lua index cf851eef..806eb9bd 100644 --- a/plugins/mod_auth_internal_hashed.lua +++ b/plugins/mod_auth_internal_hashed.lua @@ -9,26 +9,27 @@ local max = math.max; -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; -local hex = require"util.hex"; +local scram_hashers = require "prosody.util.sasl.scram".hashers; +local generate_uuid = require "prosody.util.uuid".generate; +local new_sasl = require "prosody.util.sasl".new; +local hex = require"prosody.util.hex"; local to_hex, from_hex = hex.encode, hex.decode; -local saslprep = require "util.encodings".stringprep.saslprep; -local secure_equals = require "util.hashes".equals; +local saslprep = require "prosody.util.encodings".stringprep.saslprep; +local secure_equals = require "prosody.util.hashes".equals; local log = module._log; local host = module.host; local accounts = module:open_store("accounts"); -local hash_name = module:get_option_string("password_hash", "SHA-1"); +local hash_name = module:get_option_enum("password_hash", "SHA-1", "SHA-256"); 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 = module:get_option_number("default_iteration_count", 10000); +local default_iteration_count = module:get_option_integer("default_iteration_count", 10000, 4096); + +local tokenauth = module:depends("tokenauth"); -- define auth provider local provider = {}; @@ -36,6 +37,9 @@ local provider = {}; function provider.test_password(username, password) log("debug", "test password for user '%s'", username); local credentials = accounts:get(username) or {}; + if credentials.disabled then + return nil, "Account disabled."; + end password = saslprep(password); if not password then return nil, "Password fails SASLprep."; @@ -86,11 +90,22 @@ function provider.set_password(username, password) account.server_key = server_key_hex account.password = nil; + account.updated = os.time(); return accounts:set(username, account); end return nil, "Account not available."; end +function provider.get_account_info(username) + local account = accounts:get(username); + if not account then return nil, "Account not available"; end + return { + created = account.created; + password_updated = account.updated; + enabled = not account.disabled; + }; +end + function provider.user_exists(username) local account = accounts:get(username); if not account then @@ -100,13 +115,36 @@ function provider.user_exists(username) return true; end +function provider.is_enabled(username) -- luacheck: ignore 212 + local info, err = provider.get_account_info(username); + if not info then return nil, err; end + return info.enabled; +end + +function provider.enable(username) + -- TODO map store? + local account = accounts:get(username); + account.disabled = nil; + account.updated = os.time(); + return accounts:set(username, account); +end + +function provider.disable(username, meta) + local account = accounts:get(username); + account.disabled = true; + account.disabled_meta = meta; + account.updated = os.time(); + return accounts:set(username, account); +end + function provider.users() return accounts:users(); end function provider.create_user(username, password) + local now = os.time(); if password == nil then - return accounts:set(username, {}); + return accounts:set(username, { created = now; updated = now; disabled = true }); end local salt = generate_uuid(); local valid, stored_key, server_key = get_auth_db(password, salt, default_iteration_count); @@ -117,7 +155,8 @@ function provider.create_user(username, password) local server_key_hex = to_hex(server_key); return accounts:set(username, { stored_key = stored_key_hex, server_key = server_key_hex, - salt = salt, iteration_count = default_iteration_count + salt = salt, iteration_count = default_iteration_count, + created = now, updated = now; }); end @@ -127,8 +166,8 @@ end function provider.get_sasl_handler() local testpass_authentication_profile = { - plain_test = function(_, username, password, realm) - return usermanager.test_password(username, realm, password), true; + plain_test = function(_, username, password) + return provider.test_password(username, password), provider.is_enabled(username); end, [scram_name] = function(_, username) local credentials = accounts:get(username); @@ -145,8 +184,9 @@ function provider.get_sasl_handler() local iteration_count, salt = credentials.iteration_count, credentials.salt; stored_key = stored_key and from_hex(stored_key); server_key = server_key and from_hex(server_key); - return stored_key, server_key, iteration_count, salt, true; - end + return stored_key, server_key, iteration_count, salt, not credentials.disabled; + end; + oauthbearer = tokenauth.sasl_handler(provider, "oauth2", module:shared("tokenauth/oauthbearer_config")); }; return new_sasl(host, testpass_authentication_profile); end diff --git a/plugins/mod_auth_internal_plain.lua b/plugins/mod_auth_internal_plain.lua index 8a50e820..6cced803 100644 --- a/plugins/mod_auth_internal_plain.lua +++ b/plugins/mod_auth_internal_plain.lua @@ -6,10 +6,10 @@ -- COPYING file in the source package for more information. -- -local usermanager = require "core.usermanager"; -local new_sasl = require "util.sasl".new; -local saslprep = require "util.encodings".stringprep.saslprep; -local secure_equals = require "util.hashes".equals; +local usermanager = require "prosody.core.usermanager"; +local new_sasl = require "prosody.util.sasl".new; +local saslprep = require "prosody.util.encodings".stringprep.saslprep; +local secure_equals = require "prosody.util.hashes".equals; local log = module._log; local host = module.host; @@ -22,6 +22,9 @@ local provider = {}; function provider.test_password(username, password) log("debug", "test password for user '%s'", username); local credentials = accounts:get(username) or {}; + if credentials.disabled then + return nil, "Account disabled."; + end password = saslprep(password); if not password then return nil, "Password fails SASLprep."; @@ -48,11 +51,21 @@ function provider.set_password(username, password) local account = accounts:get(username); if account then account.password = password; + account.updated = os.time(); return accounts:set(username, account); end return nil, "Account not available."; end +function provider.get_account_info(username) + local account = accounts:get(username); + if not account then return nil, "Account not available"; end + return { + created = account.created; + password_updated = account.updated; + }; +end + function provider.user_exists(username) local account = accounts:get(username); if not account then @@ -67,11 +80,18 @@ function provider.users() end function provider.create_user(username, password) + local now = os.time(); + if password == nil then + return accounts:set(username, { created = now, updated = now, disabled = true }); + end password = saslprep(password); if not password then return nil, "Password fails SASLprep."; end - return accounts:set(username, {password = password}); + return accounts:set(username, { + password = password; + created = now, updated = now; + }); end function provider.delete_user(username) diff --git a/plugins/mod_auth_ldap.lua b/plugins/mod_auth_ldap.lua index 4d484aaa..569cef6b 100644 --- a/plugins/mod_auth_ldap.lua +++ b/plugins/mod_auth_ldap.lua @@ -1,7 +1,6 @@ -- mod_auth_ldap -local jid_split = require "util.jid".split; -local new_sasl = require "util.sasl".new; +local new_sasl = require "prosody.util.sasl".new; local lualdap = require "lualdap"; local function ldap_filter_escape(s) @@ -13,14 +12,21 @@ 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_scope = module:get_option_enum("ldap_scope", "subtree", "base", "onelevel"); local ldap_filter = module:get_option_string("ldap_filter", "(uid=$user)"):gsub("%%s", "$user", 1); local ldap_base = assert(module:get_option_string("ldap_base"), "ldap_base is a required option for ldap"); -local ldap_mode = module:get_option_string("ldap_mode", "bind"); +local ldap_mode = module:get_option_enum("ldap_mode", "bind", "getpasswd"); 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)); +if ldap_admins then + module:log("error", "The 'ldap_admin_filter' option has been deprecated, ".. + "and will be ignored. Equivalent functionality may be added in ".. + "the future if there is demand." + ); +end + -- Initiate connection local ld = nil; module.unload = function() if ld then pcall(ld, ld.close); end end @@ -133,22 +139,4 @@ 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 index 17687959..96324734 100644 --- a/plugins/mod_authz_internal.lua +++ b/plugins/mod_authz_internal.lua @@ -1,59 +1,350 @@ -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 array = require "prosody.util.array"; +local it = require "prosody.util.iterators"; +local set = require "prosody.util.set"; +local jid_split, jid_bare, jid_host = import("prosody.util.jid", "split", "bare", "host"); +local normalize = require "prosody.util.jid".prep; +local roles = require "prosody.util.roles"; + +local config_global_admin_jids = module:context("*"):get_option_set("admins", {}) / normalize; 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 host_suffix = host:gsub("^[^%.]+%.", ""); + +local hosts = prosody.hosts; +local is_anon_host = module:get_option_string("authentication") == "anonymous"; +local default_user_role = module:get_option_string("default_user_role", is_anon_host and "prosody:guest" or "prosody:registered"); + +local is_component = hosts[host].type == "component"; +local host_user_role, server_user_role, public_user_role; +if is_component then + host_user_role = module:get_option_string("host_user_role", "prosody:registered"); + server_user_role = module:get_option_string("server_user_role"); + public_user_role = module:get_option_string("public_user_role"); +end + +local role_store = module:open_store("account_roles"); +local role_map_store = module:open_store("account_roles", "map"); -local admin_role = { ["prosody:admin"] = true }; +local role_registry = {}; -function get_user_roles(user) - if config_admin_jids:contains(user.."@"..host) then - return admin_role; +function register_role(role) + if role_registry[role.name] ~= nil then + return error("A role '"..role.name.."' is already registered"); end - return role_store:get(user); + if not roles.is_role(role) then + -- Convert table syntax to real role object + for i, inherited_role in ipairs(role.inherits or {}) do + if type(inherited_role) == "string" then + role.inherits[i] = assert(role_registry[inherited_role], "The named role '"..inherited_role.."' is not registered"); + end + end + if not role.permissions then role.permissions = {}; end + for _, allow_permission in ipairs(role.allow or {}) do + role.permissions[allow_permission] = true; + end + for _, deny_permission in ipairs(role.deny or {}) do + role.permissions[deny_permission] = false; + end + role = roles.new(role); + end + role_registry[role.name] = role; end -function set_user_roles(user, roles) - role_store:set(user, roles) - return true; +-- Default roles + +-- For untrusted guest/anonymous users +register_role { + name = "prosody:guest"; + priority = 15; +}; + +-- For e.g. self-registered accounts +register_role { + name = "prosody:registered"; + priority = 25; + inherits = { "prosody:guest" }; +}; + + +-- For trusted/provisioned accounts +register_role { + name = "prosody:member"; + priority = 35; + inherits = { "prosody:registered" }; +}; + +-- For administrators, e.g. of a host +register_role { + name = "prosody:admin"; + priority = 50; + inherits = { "prosody:member" }; +}; + +-- For server operators (full access) +register_role { + name = "prosody:operator"; + priority = 75; + inherits = { "prosody:admin" }; +}; + + +-- Process custom roles from config + +local custom_roles = module:get_option_array("custom_roles", {}); +for n, role_config in ipairs(custom_roles) do + local ok, err = pcall(register_role, role_config); + if not ok then + module:log("error", "Error registering custom role %s: %s", role_config.name or tostring(n), err); + end 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) +-- Process custom permissions from config + +local config_add_perms = module:get_option("add_permissions", {}); +local config_remove_perms = module:get_option("remove_permissions", {}); + +for role_name, added_permissions in pairs(config_add_perms) do + if not role_registry[role_name] then + module:log("error", "Cannot add permissions to unknown role '%s'", role_name); + else + for _, permission in ipairs(added_permissions) do + role_registry[role_name]:set_permission(permission, true, true); + end + end +end + +for role_name, removed_permissions in pairs(config_remove_perms) do + if not role_registry[role_name] then + module:log("error", "Cannot remove permissions from unknown role '%s'", role_name); + else + for _, permission in ipairs(removed_permissions) do + role_registry[role_name]:set_permission(permission, false, true); + end + end +end + +-- Public API + +-- Get the primary role of a user +function get_user_role(user) + local bare_jid = user.."@"..host; + + -- Check config first + if config_global_admin_jids:contains(bare_jid) then + return role_registry["prosody:operator"]; + elseif config_admin_jids:contains(bare_jid) then + return role_registry["prosody:admin"]; + end + + -- Check storage + local stored_roles, err = role_store:get(user); + if not stored_roles then + if err then + -- Unable to fetch role, fail + return nil, err; + end + -- No role set, use default role + return role_registry[default_user_role]; + end + if stored_roles._default == nil then + -- No primary role explicitly set, return default + return role_registry[default_user_role]; + end + local primary_stored_role = role_registry[stored_roles._default]; + if not primary_stored_role then + return nil, "unknown-role"; + end + return primary_stored_role; +end + +-- Set the primary role of a user +function set_user_role(user, role_name) + local role = role_registry[role_name]; + if not role then + return error("Cannot assign default user an unknown role: "..tostring(role_name)); + end + local keys_update = { + _default = role_name; + -- Primary role cannot be secondary role + [role_name] = role_map_store.remove; + }; + if role_name == default_user_role then + -- Don't store default + keys_update._default = role_map_store.remove; + end + local ok, err = role_map_store:set_keys(user, keys_update); + if not ok then + return nil, err; + end + return role; +end + +function add_user_secondary_role(user, role_name) + if not role_registry[role_name] then + return error("Cannot assign default user an unknown role: "..tostring(role_name)); + end + role_map_store:set(user, role_name, true); +end + +function remove_user_secondary_role(user, role_name) + role_map_store:set(user, role_name, nil); +end + +function get_user_secondary_roles(user) + local stored_roles, err = role_store:get(user); + if not stored_roles then + if err then + -- Unable to fetch role, fail + return nil, err; + end + -- No role set + return {}; + end + stored_roles._default = nil; + for role_name in pairs(stored_roles) do + stored_roles[role_name] = role_registry[role_name]; + end + return stored_roles; +end + +function user_can_assume_role(user, role_name) + local primary_role = get_user_role(user); + if primary_role and primary_role.name == role_name then + return true; + end + local secondary_roles = get_user_secondary_roles(user); + if secondary_roles and secondary_roles[role_name] then + return true; + end + return false; +end + +-- This function is *expensive* +function get_users_with_role(role_name) + local function role_filter(username, default_role) --luacheck: ignore 212/username + return default_role == role_name; + end + local primary_role_users = set.new(it.to_array(it.filter(role_filter, pairs(role_map_store:get_all("_default") or {})))); + local secondary_role_users = set.new(it.to_array(it.keys(role_map_store:get_all(role_name) or {}))); + + local config_set; + if role_name == "prosody:admin" then + config_set = config_admin_jids; + elseif role_name == "prosody:operator" then + config_set = config_global_admin_jids; + end + if config_set then + local config_admin_users = config_set / 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)); + return it.to_array(config_admin_users + primary_role_users + secondary_role_users); end - return storage_role_users; + return it.to_array(primary_role_users + secondary_role_users); end -function get_jid_roles(jid) - if config_admin_jids:contains(jid) then - return admin_role; +function get_jid_role(jid) + local bare_jid = jid_bare(jid); + if config_global_admin_jids:contains(bare_jid) then + return role_registry["prosody:operator"]; + elseif config_admin_jids:contains(bare_jid) then + return role_registry["prosody:admin"]; + elseif is_component then + local user_host = jid_host(bare_jid); + if host_user_role and user_host == host_suffix then + return role_registry[host_user_role]; + elseif server_user_role and hosts[user_host] then + return role_registry[server_user_role]; + elseif public_user_role then + return role_registry[public_user_role]; + end end return nil; end -function set_jid_roles(jid) -- luacheck: ignore 212 +function set_jid_role(jid, role_name) -- luacheck: ignore 212 return false; end -function get_jids_with_role(role) +function get_jids_with_role(role_name) -- Fetch role users from storage - local storage_role_jids = array.map(get_users_with_role(role), function (username) + local storage_role_jids = array.map(get_users_with_role(role_name), function (username) return username.."@"..host; end); - if role == "prosody:admin" then + if role_name == "prosody:admin" then return it.to_array(config_admin_jids + set.new(storage_role_jids)); + elseif role_name == "prosody:operator" then + return it.to_array(config_global_admin_jids + set.new(storage_role_jids)); end return storage_role_jids; end + +function add_default_permission(role_name, action, policy) + local role = role_registry[role_name]; + if not role then + module:log("warn", "Attempt to add default permission for unknown role: %s", role_name); + return nil, "no-such-role"; + end + if policy == nil then policy = true; end + module:log("debug", "Adding policy %s for permission %s on role %s", policy, action, role_name); + return role:set_permission(action, policy); +end + +function get_role_by_name(role_name) + return assert(role_registry[role_name], role_name); +end + +function get_all_roles() + return role_registry; +end + +-- COMPAT: Migrate from 0.12 role storage +local function do_migration(migrate_host) + local old_role_store = assert(module:context(migrate_host):open_store("roles")); + local new_role_store = assert(module:context(migrate_host):open_store("account_roles")); + + local migrated, failed, skipped = 0, 0, 0; + -- Iterate all users + for username in assert(old_role_store:users()) do + local old_roles = it.to_array(it.filter(function (k) return k:sub(1,1) ~= "_"; end, it.keys(old_role_store:get(username)))); + if #old_roles == 1 then + local ok, err = new_role_store:set(username, { + _default = old_roles[1]; + }); + if ok then + migrated = migrated + 1; + else + failed = failed + 1; + print("EE: Failed to store new role info for '"..username.."': "..err); + end + else + print("WW: User '"..username.."' has multiple roles and cannot be automatically migrated"); + skipped = skipped + 1; + end + end + return migrated, failed, skipped; +end + +function module.command(arg) + if arg[1] == "migrate" then + table.remove(arg, 1); + local migrate_host = arg[1]; + if not migrate_host or not prosody.hosts[migrate_host] then + print("EE: Please supply a valid host to migrate to the new role storage"); + return 1; + end + + -- Initialize storage layer + require "prosody.core.storagemanager".initialize_host(migrate_host); + + print("II: Migrating roles..."); + local migrated, failed, skipped = do_migration(migrate_host); + print(("II: %d migrated, %d failed, %d skipped"):format(migrated, failed, skipped)); + return (failed + skipped == 0) and 0 or 1; + else + print("EE: Unknown command: "..(arg[1] or "<none given>")); + print(" Hint: try 'migrate'?"); + end +end diff --git a/plugins/mod_blocklist.lua b/plugins/mod_blocklist.lua index 6b8ce16c..6587c8b1 100644 --- a/plugins/mod_blocklist.lua +++ b/plugins/mod_blocklist.lua @@ -9,34 +9,29 @@ -- This module implements XEP-0191: Blocking Command -- -local user_exists = require"core.usermanager".user_exists; -local rostermanager = require"core.rostermanager"; +local user_exists = require"prosody.core.usermanager".user_exists; +local rostermanager = require"prosody.core.rostermanager"; local is_contact_subscribed = rostermanager.is_contact_subscribed; local is_contact_pending_in = rostermanager.is_contact_pending_in; local load_roster = rostermanager.load_roster; local save_roster = rostermanager.save_roster; -local st = require"util.stanza"; +local st = require"prosody.util.stanza"; local st_error_reply = st.error_reply; -local jid_prep = require"util.jid".prep; -local jid_split = require"util.jid".split; +local jid_prep = require"prosody.util.jid".prep; +local jid_split = require"prosody.util.jid".split; local storage = module:open_store(); local sessions = prosody.hosts[module.host].sessions; local full_sessions = prosody.full_sessions; --- First level cache of blocklists by username. --- Weak table so may randomly expire at any time. -local cache = setmetatable({}, { __mode = "v" }); - --- Second level of caching, keeps a fixed number of items, also anchors --- items in the above cache. +-- Cache of blocklists, keeps a fixed number of items. -- -- The size of this affects how often we will need to load a blocklist from -- disk, which we want to avoid during routing. On the other hand, we don't -- want to use too much memory either, so this can be tuned by advanced -- users. TODO use science to figure out a better default, 64 is just a guess. -local cache_size = module:get_option_number("blocklist_cache_size", 64); -local cache2 = require"util.cache".new(cache_size); +local cache_size = module:get_option_integer("blocklist_cache_size", 256, 1); +local blocklist_cache = require"prosody.util.cache".new(cache_size); local null_blocklist = {}; @@ -48,12 +43,12 @@ local function set_blocklist(username, blocklist) return ok, err; end -- Successful save, update the cache - cache2:set(username, blocklist); - cache[username] = blocklist; + blocklist_cache:set(username, blocklist); return true; end -- Migrates from the old mod_privacy storage +-- TODO mod_privacy was removed in 0.10.0, this should be phased out local function migrate_privacy_list(username) local legacy_data = module:open_store("privacy"):get(username); if not legacy_data or not legacy_data.lists or not legacy_data.default then return; end @@ -77,8 +72,15 @@ local function migrate_privacy_list(username) return migrated_data; end +if not module:get_option_boolean("migrate_legacy_blocking", true) then + migrate_privacy_list = function (username) + module:log("debug", "Migrating from mod_privacy disabled, user '%s' will start with a fresh blocklist", username); + return nil; + end +end + local function get_blocklist(username) - local blocklist = cache2:get(username); + local blocklist = blocklist_cache:get(username); if not blocklist then if not user_exists(username, module.host) then return null_blocklist; @@ -90,9 +92,8 @@ local function get_blocklist(username) if not blocklist then blocklist = { [false] = { created = os.time(); }; }; end - cache2:set(username, blocklist); + blocklist_cache:set(username, blocklist); end - cache[username] = blocklist; return blocklist; end @@ -100,7 +101,7 @@ module:hook("iq-get/self/urn:xmpp:blocking:blocklist", function (event) local origin, stanza = event.origin, event.stanza; local username = origin.username; local reply = st.reply(stanza):tag("blocklist", { xmlns = "urn:xmpp:blocking" }); - local blocklist = cache[username] or get_blocklist(username); + local blocklist = get_blocklist(username); for jid in pairs(blocklist) do if jid then reply:tag("item", { jid = jid }):up(); @@ -159,7 +160,7 @@ local function edit_blocklist(event) return true; end - local blocklist = cache[username] or get_blocklist(username); + local blocklist = get_blocklist(username); local new_blocklist = { -- We set the [false] key to something as a signal not to migrate privacy lists @@ -233,8 +234,7 @@ module:hook("iq-set/self/urn:xmpp:blocking:unblock", edit_blocklist, -1); -- Cache invalidation, solved! module:hook_global("user-deleted", function (event) if event.host == module.host then - cache2:set(event.username, nil); - cache[event.username] = nil; + blocklist_cache:set(event.username, nil); end end); @@ -249,7 +249,7 @@ module:hook("iq-error/self/blocklist-push", function (event) end); local function is_blocked(user, jid) - local blocklist = cache[user] or get_blocklist(user); + local blocklist = get_blocklist(user); if blocklist[jid] then return true; end local node, host = jid_split(jid); return blocklist[host] or node and blocklist[node..'@'..host]; @@ -262,7 +262,20 @@ local function drop_stanza(event) local to, from = attr.to, attr.from; to = to and jid_split(to); if to and from then - return is_blocked(to, from); + if is_blocked(to, from) then + return true; + end + + -- Check mediated MUC inviter + if stanza.name == "message" then + local invite = stanza:find("{http://jabber.org/protocol/muc#user}x/invite"); + if invite then + from = jid_prep(invite.attr.from); + if is_blocked(to, from) then + return true; + end + end + end end end diff --git a/plugins/mod_bookmarks.lua b/plugins/mod_bookmarks.lua index d67915f8..be665d0f 100644 --- a/plugins/mod_bookmarks.lua +++ b/plugins/mod_bookmarks.lua @@ -1,10 +1,10 @@ -local mm = require "core.modulemanager"; +local mm = require "prosody.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 st = require "prosody.util.stanza"; +local jid_split = require "prosody.util.jid".split; local mod_pep = module:depends "pep"; local private_storage = module:open_store("private", "map"); diff --git a/plugins/mod_bosh.lua b/plugins/mod_bosh.lua index 11bfb51d..091a7d81 100644 --- a/plugins/mod_bosh.lua +++ b/plugins/mod_bosh.lua @@ -8,21 +8,21 @@ module:set_global(); -local new_xmpp_stream = require "util.xmppstream".new; -local sm = require "core.sessionmanager"; +local new_xmpp_stream = require "prosody.util.xmppstream".new; +local sm = require "prosody.core.sessionmanager"; local sm_destroy_session = sm.destroy_session; -local new_uuid = require "util.uuid".generate; +local new_uuid = require "prosody.util.uuid".generate; local core_process_stanza = prosody.core_process_stanza; -local st = require "util.stanza"; -local logger = require "util.logger"; +local st = require "prosody.util.stanza"; +local logger = require "prosody.util.logger"; local log = module._log; -local initialize_filters = require "util.filters".initialize; +local initialize_filters = require "prosody.util.filters".initialize; local math_min = math.min; local tostring, type = tostring, type; local traceback = debug.traceback; -local runner = require"util.async".runner; -local nameprep = require "util.encodings".stringprep.nameprep; -local cache = require "util.cache"; +local runner = require"prosody.util.async".runner; +local nameprep = require "prosody.util.encodings".stringprep.nameprep; +local cache = require "prosody.util.cache"; local xmlns_streams = "http://etherx.jabber.org/streams"; local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; @@ -36,16 +36,16 @@ local BOSH_HOLD = 1; local BOSH_MAX_REQUESTS = 2; -- The number of seconds a BOSH session should remain open with no requests -local bosh_max_inactivity = module:get_option_number("bosh_max_inactivity", 60); +local bosh_max_inactivity = module:get_option_period("bosh_max_inactivity", 60); -- The minimum amount of time between requests with no payload -local bosh_max_polling = module:get_option_number("bosh_max_polling", 5); +local bosh_max_polling = module:get_option_period("bosh_max_polling", 5); -- The maximum amount of time that the server will hold onto a request before replying -- (the client can set this to a lower value when it connects, if it chooses) -local bosh_max_wait = module:get_option_number("bosh_max_wait", 120); +local bosh_max_wait = module:get_option_period("bosh_max_wait", 120); local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure"); local cross_domain = module:get_option("cross_domain_bosh"); -local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024*256); +local stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024*256, 10000); if cross_domain ~= nil then module:log("info", "The 'cross_domain_bosh' option has been deprecated"); @@ -325,7 +325,7 @@ function stream_callbacks.streamopened(context, attr) sid = new_uuid(); -- TODO use util.session local session = { - type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to, + base_type = "c2s", 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 bosh_version = attr.ver, bosh_wait = wait, streamid = sid, bosh_max_inactive = bosh_max_inactivity, bosh_responses = cache.new(BOSH_HOLD+1):table(); @@ -456,7 +456,7 @@ function stream_callbacks.streamopened(context, attr) if session.notopen then local features = st.stanza("stream:features"); - module:context(session.host):fire_event("stream-features", { origin = session, features = features }); + module:context(session.host):fire_event("stream-features", { origin = session, features = features, stream = attr }); session.send(features); session.notopen = nil; end @@ -559,6 +559,6 @@ function module.add_host(module) }); end -if require"core.modulemanager".get_modules_for_host("*"):contains(module.name) then +if require"prosody.core.modulemanager".get_modules_for_host("*"):contains(module.name) then module:add_host(); end diff --git a/plugins/mod_c2s.lua b/plugins/mod_c2s.lua index c8f54fa7..09d4be08 100644 --- a/plugins/mod_c2s.lua +++ b/plugins/mod_c2s.lua @@ -8,15 +8,15 @@ module:set_global(); -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 add_task = require "prosody.util.timer".add_task; +local new_xmpp_stream = require "prosody.util.xmppstream".new; +local nameprep = require "prosody.util.encodings".stringprep.nameprep; +local sessionmanager = require "prosody.core.sessionmanager"; +local statsmanager = require "prosody.core.statsmanager"; +local st = require "prosody.util.stanza"; local sm_new_session, sm_destroy_session = sessionmanager.new_session, sessionmanager.destroy_session; -local uuid_generate = require "util.uuid".generate; -local async = require "util.async"; +local uuid_generate = require "prosody.util.uuid".generate; +local async = require "prosody.util.async"; local runner = async.runner; local tostring, type = tostring, type; @@ -25,10 +25,16 @@ local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; local log = module._log; -local c2s_timeout = module:get_option_number("c2s_timeout", 300); -local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5); +local c2s_timeout = module:get_option_period("c2s_timeout", "5 minutes"); +local stream_close_timeout = module:get_option_period("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 stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024*256,10000); + +local advertised_idle_timeout = 14*60; -- default in all net.server implementations +local network_settings = module:get_option("network_settings"); +if type(network_settings) == "table" and type(network_settings.read_timeout) == "number" then + advertised_idle_timeout = network_settings.read_timeout; +end local measure_connections = module:metric("gauge", "connections", "", "Established c2s connections", {"host", "type", "ip_family"}); @@ -117,8 +123,7 @@ function stream_callbacks._streamopened(session, attr) session.secure = true; session.encrypted = true; - local sock = session.conn:socket(); - local info = sock.info and sock:info(); + local info = session.conn:ssl_info(); if type(info) == "table" then (session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher); session.compressed = info.compression; @@ -129,8 +134,19 @@ function stream_callbacks._streamopened(session, attr) end local features = st.stanza("stream:features"); - hosts[session.host].events.fire_event("stream-features", { origin = session, features = features }); + hosts[session.host].events.fire_event("stream-features", { origin = session, features = features, stream = attr }); if features.tags[1] or session.full_jid then + if stanza_size_limit or advertised_idle_timeout then + features:reset(); + local limits = features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" }); + if stanza_size_limit then + limits:text_tag("max-bytes", string.format("%d", stanza_size_limit)); + end + if advertised_idle_timeout then + limits:text_tag("idle-seconds", string.format("%d", advertised_idle_timeout)); + end + limits:reset(); + end send(features); else if session.secure then @@ -248,6 +264,9 @@ end local function disconnect_user_sessions(reason, leave_resource) return function (event) local username, host, resource = event.username, event.host, event.resource; + if not (hosts[host] and hosts[host].type == "local") then + return -- not a local VirtualHost so no sessions + end local user = hosts[host].sessions[username]; if user and user.sessions then for r, session in pairs(user.sessions) do @@ -260,8 +279,18 @@ local function disconnect_user_sessions(reason, leave_resource) 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-role-changed", disconnect_user_sessions({ condition = "reset", text = "Role changed" }), 200); module:hook_global("user-deleted", disconnect_user_sessions({ condition = "not-authorized", text = "Account deleted" }), 200); +module:hook_global("user-disabled", disconnect_user_sessions({ condition = "not-authorized", text = "Account disabled" }), 200); + +module:hook_global("c2s-session-updated", function (event) + sessions[event.session.conn] = event.session; + local replaced_conn = event.replaced_conn; + if replaced_conn then + sessions[replaced_conn] = nil; + replaced_conn:close(); + end +end); function runner_callbacks:ready() if self.data.conn then @@ -293,10 +322,10 @@ function listener.onconnect(conn) if conn:ssl() then session.secure = true; session.encrypted = true; + session.ssl_ctx = conn:sslctx(); -- Check if TLS compression is used - local sock = conn:socket(); - local info = sock.info and sock:info(); + local info = conn:ssl_info(); if type(info) == "table" then (session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher); session.compressed = info.compression; @@ -354,11 +383,13 @@ function listener.onconnect(conn) end end - if c2s_timeout then - add_task(c2s_timeout, function () + if c2s_timeout < math.huge then + session.c2s_timeout = 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"); + else + session.c2s_timeout = nil; end end); end @@ -426,7 +457,7 @@ module:hook("c2s-read-timeout", keepalive, -1); module:hook("server-stopping", function(event) -- luacheck: ignore 212/event -- Close ports - local pm = require "core.portmanager"; + local pm = require "prosody.core.portmanager"; for _, netservice in pairs(module.items["net-provider"]) do pm.unregister_service(netservice.name, netservice); end diff --git a/plugins/mod_carbons.lua b/plugins/mod_carbons.lua index 7a5b757c..3fa34be7 100644 --- a/plugins/mod_carbons.lua +++ b/plugins/mod_carbons.lua @@ -3,9 +3,9 @@ -- -- This file is MIT/X11 licensed. -local st = require "util.stanza"; -local jid_bare = require "util.jid".bare; -local jid_resource = require "util.jid".resource; +local st = require "prosody.util.stanza"; +local jid_bare = require "prosody.util.jid".bare; +local jid_resource = require "prosody.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; diff --git a/plugins/mod_component.lua b/plugins/mod_component.lua index f57c4381..86ceb980 100644 --- a/plugins/mod_component.lua +++ b/plugins/mod_component.lua @@ -10,16 +10,16 @@ module:set_global(); local t_concat = table.concat; local tostring, type = tostring, type; -local xpcall = require "util.xpcall".xpcall; +local xpcall = require "prosody.util.xpcall".xpcall; local traceback = debug.traceback; -local logger = require "util.logger"; -local sha1 = require "util.hashes".sha1; -local st = require "util.stanza"; +local logger = require "prosody.util.logger"; +local sha1 = require "prosody.util.hashes".sha1; +local st = require "prosody.util.stanza"; -local jid_split = require "util.jid".split; -local new_xmpp_stream = require "util.xmppstream".new; -local uuid_gen = require "util.uuid".generate; +local jid_host = require "prosody.util.jid".host; +local new_xmpp_stream = require "prosody.util.xmppstream".new; +local uuid_gen = require "prosody.util.uuid".generate; local core_process_stanza = prosody.core_process_stanza; local hosts = prosody.hosts; @@ -27,7 +27,8 @@ local hosts = prosody.hosts; local log = module._log; local opt_keepalives = module:get_option_boolean("component_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true)); -local stanza_size_limit = module:get_option_number("component_stanza_size_limit", module:get_option_number("s2s_stanza_size_limit", 1024*512)); +local stanza_size_limit = module:get_option_integer("component_stanza_size_limit", + module:get_option_integer("s2s_stanza_size_limit", 1024 * 512, 10000), 10000); local sessions = module:shared("sessions"); @@ -85,7 +86,7 @@ function module.add_host(module) end if env.connected then - local policy = module:get_option_string("component_conflict_resolve", "kick_new"); + local policy = module:get_option_enum("component_conflict_resolve", "kick_new", "kick_old"); if policy == "kick_old" then env.session:close{ condition = "conflict", text = "Replaced by a new connection" }; else -- kick_new @@ -222,22 +223,19 @@ function stream_callbacks.handlestanza(session, stanza) end if not stanza.attr.xmlns or stanza.attr.xmlns == "jabber:client" then local from = stanza.attr.from; - if from then - if session.component_validate_from then - local _, domain = jid_split(stanza.attr.from); - if domain ~= session.host then - -- Return error - session.log("warn", "Component sent stanza with missing or invalid 'from' address"); - session:close{ - condition = "invalid-from"; - text = "Component tried to send from address <"..tostring(from) - .."> which is not in domain <"..tostring(session.host)..">"; - }; - return; - end + if session.component_validate_from then + if not from or (jid_host(from) ~= session.host) then + -- Return error + session.log("warn", "Component sent stanza with missing or invalid 'from' address"); + session:close{ + condition = "invalid-from"; + text = "Component tried to send from address <"..(from or "< [missing 'from' attribute] >") + .."> which is not in domain <"..tostring(session.host)..">"; + }; + return; end - else - stanza.attr.from = session.host; -- COMPAT: Strictly we shouldn't allow this + elseif not from then + stanza.attr.from = session.host; end if not stanza.attr.to then session.log("warn", "Rejecting stanza with no 'to' address"); diff --git a/plugins/mod_cron.lua b/plugins/mod_cron.lua index 33d97df6..29c1aa93 100644 --- a/plugins/mod_cron.lua +++ b/plugins/mod_cron.lua @@ -1,9 +1,10 @@ module:set_global(); -local async = require("util.async"); -local datetime = require("util.datetime"); +local async = require("prosody.util.async"); -local periods = { hourly = 3600; daily = 86400; weekly = 7 * 86400 } +local cron_initial_delay = module:get_option_number("cron_initial_delay", 1); +local cron_check_delay = module:get_option_number("cron_check_delay", 3600); +local cron_spread_factor = module:get_option_number("cron_spread_factor", 0); local active_hosts = {} @@ -14,18 +15,16 @@ function module.add_host(host_module) local function save_task(task, started_at) last_run_times:set(nil, task.id, started_at); end + local function restore_task(task) if task.last == nil then task.last = last_run_times:get(nil, task.id); end 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.period = host_module:get_option_period(task.id:gsub("/", "_") .. "_period", "1" .. task.when, 60, 86400 * 7 * 53); + task.restore = restore_task; 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 + module:log("debug", "%s task %s added", task.when, task.id); return true end @@ -40,26 +39,55 @@ function module.add_host(host_module) 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 should_run(task, last) return not last or last + task.period * 0.995 <= os.time() end local function run_task(task) + task:restore(); + if not should_run(task, task.last) then return end local started_at = os.time(); task:run(started_at); task.last = started_at; task:save(started_at); end +local function spread(t, factor) + return t * (1 - factor + 2*factor*math.random()); +end + local task_runner = async.runner(run_task); -scheduled = module:add_timer(1, function() +scheduled = module:add_timer(cron_initial_delay, function() module:log("info", "Running periodic tasks"); - local delay = 3600; + local delay = spread(cron_check_delay, cron_spread_factor); 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 + for _, task in ipairs(module:context(host):get_host_items("task")) do task_runner:run(task); end end - module:log("debug", "Wait %ds", delay); + module:log("debug", "Wait %gs", delay); return delay end); + +module:add_item("shell-command", { + section = "cron"; + section_desc = "View and manage recurring tasks"; + name = "tasks"; + desc = "View registered tasks"; + args = {}; + handler = function(self, filter_host) + local format_table = require("prosody.util.human.io").table; + local it = require("util.iterators"); + local row = format_table({ + { title = "Host"; width = "2p" }; + { title = "Task"; width = "3p" }; + { title = "Desc"; width = "3p" }; + { title = "When"; width = "1p" }; + { title = "Last run"; width = "20" }; + }, self.session.width); + local print = self.session.print; + print(row()); + for host in it.sorted_pairs(filter_host and { [filter_host] = true } or active_hosts) do + for _, task in ipairs(module:context(host):get_host_items("task")) do + print(row({ host; task.id; task.name; task.when; task.last and os.date("%Y-%m-%d %R:%S", task.last) or "never" })); + end + end + end; +}); diff --git a/plugins/mod_csi.lua b/plugins/mod_csi.lua index 458ff491..7a1857c0 100644 --- a/plugins/mod_csi.lua +++ b/plugins/mod_csi.lua @@ -1,10 +1,12 @@ -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local xmlns_csi = "urn:xmpp:csi:0"; local csi_feature = st.stanza("csi", { xmlns = xmlns_csi }); -local csi_handler_available = nil; +local change = module:metric("counter", "changes", "events", "CSI state changes", {"csi_state"}); +local count = module:metric("gauge", "state", "sessions", "", { "state" }); + module:hook("stream-features", function (event) - if event.origin.username and csi_handler_available then + if event.origin.username then event.features:add_child(csi_feature); end end); @@ -13,6 +15,7 @@ function refire_event(name) return function (event) if event.origin.username then event.origin.state = event.stanza.name; + change:with_labels(event.stanza.name):add(1); module:fire_event(name, event); return true; end @@ -22,14 +25,22 @@ 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"); +module:hook_global("stats-update", function() + local sessions = prosody.hosts[module.host].sessions; + if not sessions then return end + local active, inactive, flushing = 0, 0, 0; + for _, user_session in pairs(sessions) do + for _, session in pairs(user_session.sessions) do + if session.state == "inactive" then + inactive = inactive + 1; + elseif session.state == "active" then + inactive = inactive + 1; + elseif session.state == "flushing" then + inactive = inactive + 1; + end + end end -end -module:hook("module-loaded", module.load); -module:hook("module-unloaded", module.load); + count:with_labels("active"):set(active); + count:with_labels("inactive"):set(inactive); + count:with_labels("flushing"):set(flushing); +end); diff --git a/plugins/mod_csi_simple.lua b/plugins/mod_csi_simple.lua index fdd1fd6c..379371ef 100644 --- a/plugins/mod_csi_simple.lua +++ b/plugins/mod_csi_simple.lua @@ -6,14 +6,14 @@ module:depends"csi" -local jid = require "util.jid"; -local st = require "util.stanza"; -local dt = require "util.datetime"; -local filters = require "util.filters"; -local timer = require "util.timer"; +local jid = require "prosody.util.jid"; +local st = require "prosody.util.stanza"; +local dt = require "prosody.util.datetime"; +local filters = require "prosody.util.filters"; +local timer = require "prosody.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); +local queue_size = module:get_option_integer("csi_queue_size", 256, 1); +local resume_delay = module:get_option_period("csi_resume_inactive_delay", 5); local important_payloads = module:get_option_set("csi_important_payloads", { }); @@ -116,6 +116,9 @@ local flush_reasons = module:metric( { "reason" } ); +local flush_sizes = module:metric("histogram", "flush_stanza_count", "", "Number of stanzas flushed at once", {}, + { buckets = { 0, 1, 2, 4, 8, 16, 32, 64, 128, 256 } }):with_labels(); + local function manage_buffer(stanza, session) local ctr = session.csi_counter or 0; if session.state ~= "inactive" then @@ -129,6 +132,7 @@ local function manage_buffer(stanza, session) session.csi_measure_buffer_hold = nil; end flush_reasons:with_labels(why or "important"):add(1); + flush_sizes:sample(ctr); 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 }); @@ -147,6 +151,7 @@ local function flush_buffer(data, session) 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_sizes:sample(ctr); flush_reasons:with_labels("client activity"):add(1); if session.csi_measure_buffer_hold then session.csi_measure_buffer_hold(); @@ -258,7 +263,7 @@ function module.command(arg) return 1; end -- luacheck: ignore 212/self - local xmppstream = require "util.xmppstream"; + local xmppstream = require "prosody.util.xmppstream"; local input_session = { notopen = true } local stream_callbacks = { stream_ns = "jabber:client", default_ns = "jabber:client" }; function stream_callbacks:handlestanza(stanza) diff --git a/plugins/mod_debug_reset.lua b/plugins/mod_debug_reset.lua new file mode 100644 index 00000000..5964aff0 --- /dev/null +++ b/plugins/mod_debug_reset.lua @@ -0,0 +1,36 @@ +-- This module will "reset" the server when the client connection count drops +-- to zero. This is somewhere between a reload and a full process restart. +-- It is useful to ensure isolation between test runs, for example. It may +-- also be of use for some kinds of manual testing. + +module:set_global(); + +local hostmanager = require "prosody.core.hostmanager"; + +local function do_reset() + module:log("info", "Performing reset..."); + local hosts = {}; + for host in pairs(prosody.hosts) do + table.insert(hosts, host); + end + module:fire_event("server-resetting"); + for _, host in ipairs(hosts) do + hostmanager.deactivate(host); + hostmanager.activate(host); + module:log("info", "Reset complete"); + module:fire_event("server-reset"); + end +end + +function module.add_host(host_module) + host_module:hook("resource-unbind", function () + if next(prosody.full_sessions) == nil then + do_reset(); + end + end); +end + +local console_env = module:shared("/*/admin_shell/env"); +console_env.debug_reset = { + reset = do_reset; +}; diff --git a/plugins/mod_debug_stanzas/watcher.lib.lua b/plugins/mod_debug_stanzas/watcher.lib.lua new file mode 100644 index 00000000..1e673648 --- /dev/null +++ b/plugins/mod_debug_stanzas/watcher.lib.lua @@ -0,0 +1,220 @@ +local filters = require "prosody.util.filters"; +local jid = require "prosody.util.jid"; +local set = require "prosody.util.set"; + +local client_watchers = {}; + +-- active_filters[session] = { +-- filter_func = filter_func; +-- downstream = { cb1, cb2, ... }; +-- } +local active_filters = {}; + +local function subscribe_session_stanzas(session, handler, reason) + if active_filters[session] then + table.insert(active_filters[session].downstream, handler); + if reason then + handler(reason, nil, session); + end + return; + end + local downstream = { handler }; + active_filters[session] = { + filter_in = function (stanza) + module:log("debug", "NOTIFY WATCHER %d", #downstream); + for i = 1, #downstream do + downstream[i]("received", stanza, session); + end + return stanza; + end; + filter_out = function (stanza) + module:log("debug", "NOTIFY WATCHER %d", #downstream); + for i = 1, #downstream do + downstream[i]("sent", stanza, session); + end + return stanza; + end; + downstream = downstream; + }; + filters.add_filter(session, "stanzas/in", active_filters[session].filter_in); + filters.add_filter(session, "stanzas/out", active_filters[session].filter_out); + if reason then + handler(reason, nil, session); + end +end + +local function unsubscribe_session_stanzas(session, handler, reason) + local active_filter = active_filters[session]; + if not active_filter then + return; + end + for i = #active_filter.downstream, 1, -1 do + if active_filter.downstream[i] == handler then + table.remove(active_filter.downstream, i); + if reason then + handler(reason, nil, session); + end + end + end + if #active_filter.downstream == 0 then + filters.remove_filter(session, "stanzas/in", active_filter.filter_in); + filters.remove_filter(session, "stanzas/out", active_filter.filter_out); + end + active_filters[session] = nil; +end + +local function unsubscribe_all_from_session(session, reason) + local active_filter = active_filters[session]; + if not active_filter then + return; + end + for i = #active_filter.downstream, 1, -1 do + local handler = table.remove(active_filter.downstream, i); + if reason then + handler(reason, nil, session); + end + end + filters.remove_filter(session, "stanzas/in", active_filter.filter_in); + filters.remove_filter(session, "stanzas/out", active_filter.filter_out); + active_filters[session] = nil; +end + +local function unsubscribe_handler_from_all(handler, reason) + for session in pairs(active_filters) do + unsubscribe_session_stanzas(session, handler, reason); + end +end + +local s2s_watchers = {}; + +module:hook("s2sin-established", function (event) + for _, watcher in ipairs(s2s_watchers) do + if watcher.target_spec == event.session.from_host then + subscribe_session_stanzas(event.session, watcher.handler, "opened"); + end + end +end); + +module:hook("s2sout-established", function (event) + for _, watcher in ipairs(s2s_watchers) do + if watcher.target_spec == event.session.to_host then + subscribe_session_stanzas(event.session, watcher.handler, "opened"); + end + end +end); + +module:hook("s2s-closed", function (event) + unsubscribe_all_from_session(event.session, "closed"); +end); + +local watched_hosts = set.new(); + +local handler_map = setmetatable({}, { __mode = "kv" }); + +local function add_stanza_watcher(spec, orig_handler) + local function filtering_handler(event_type, stanza, session) + if stanza and spec.filter_spec then + if spec.filter_spec.with_jid then + if event_type == "sent" and (not stanza.attr.from or not jid.compare(stanza.attr.from, spec.filter_spec.with_jid)) then + return; + elseif event_type == "received" and (not stanza.attr.to or not jid.compare(stanza.attr.to, spec.filter_spec.with_jid)) then + return; + end + end + end + return orig_handler(event_type, stanza, session); + end + handler_map[orig_handler] = filtering_handler; + if spec.target_spec.jid then + local target_is_remote_host = not jid.node(spec.target_spec.jid) and not prosody.hosts[spec.target_spec.jid]; + + if target_is_remote_host then + -- Watch s2s sessions + table.insert(s2s_watchers, { + target_spec = spec.target_spec.jid; + handler = filtering_handler; + orig_handler = orig_handler; + }); + + -- Scan existing s2sin for matches + for session in pairs(prosody.incoming_s2s) do + if spec.target_spec.jid == session.from_host then + subscribe_session_stanzas(session, filtering_handler, "attached"); + end + end + -- Scan existing s2sout for matches + for local_host, local_session in pairs(prosody.hosts) do --luacheck: ignore 213/local_host + for remote_host, remote_session in pairs(local_session.s2sout) do + if spec.target_spec.jid == remote_host then + subscribe_session_stanzas(remote_session, filtering_handler, "attached"); + end + end + end + else + table.insert(client_watchers, { + target_spec = spec.target_spec.jid; + handler = filtering_handler; + orig_handler = orig_handler; + }); + local host = jid.host(spec.target_spec.jid); + if not watched_hosts:contains(host) and prosody.hosts[host] then + module:context(host):hook("resource-bind", function (event) + for _, watcher in ipairs(client_watchers) do + module:log("debug", "NEW CLIENT: %s vs %s", event.session.full_jid, watcher.target_spec); + if jid.compare(event.session.full_jid, watcher.target_spec) then + module:log("debug", "MATCH"); + subscribe_session_stanzas(event.session, watcher.handler, "opened"); + else + module:log("debug", "NO MATCH"); + end + end + end); + + module:context(host):hook("resource-unbind", function (event) + unsubscribe_all_from_session(event.session, "closed"); + end); + + watched_hosts:add(host); + end + for full_jid, session in pairs(prosody.full_sessions) do + if jid.compare(full_jid, spec.target_spec.jid) then + subscribe_session_stanzas(session, filtering_handler, "attached"); + end + end + end + else + error("No recognized target selector"); + end +end + +local function remove_stanza_watcher(orig_handler) + local handler = handler_map[orig_handler]; + unsubscribe_handler_from_all(handler, "detached"); + handler_map[orig_handler] = nil; + + for i = #client_watchers, 1, -1 do + if client_watchers[i].orig_handler == orig_handler then + table.remove(client_watchers, i); + end + end + + for i = #s2s_watchers, 1, -1 do + if s2s_watchers[i].orig_handler == orig_handler then + table.remove(s2s_watchers, i); + end + end +end + +local function cleanup(reason) + client_watchers = {}; + s2s_watchers = {}; + for session in pairs(active_filters) do + unsubscribe_all_from_session(session, reason or "cancelled"); + end +end + +return { + add = add_stanza_watcher; + remove = remove_stanza_watcher; + cleanup = cleanup; +}; diff --git a/plugins/mod_dialback.lua b/plugins/mod_dialback.lua index 66082333..a0a8bcb9 100644 --- a/plugins/mod_dialback.lua +++ b/plugins/mod_dialback.lua @@ -10,12 +10,12 @@ local hosts = _G.hosts; local log = module._log; -local st = require "util.stanza"; -local sha256_hash = require "util.hashes".sha256; -local sha256_hmac = require "util.hashes".hmac_sha256; -local secure_equals = require "util.hashes".equals; -local nameprep = require "util.encodings".stringprep.nameprep; -local uuid_gen = require"util.uuid".generate; +local st = require "prosody.util.stanza"; +local sha256_hash = require "prosody.util.hashes".sha256; +local sha256_hmac = require "prosody.util.hashes".hmac_sha256; +local secure_equals = require "prosody.util.hashes".equals; +local nameprep = require "prosody.util.encodings".stringprep.nameprep; +local uuid_gen = require"prosody.util.uuid".generate; local xmlns_stream = "http://etherx.jabber.org/streams"; diff --git a/plugins/mod_disco.lua b/plugins/mod_disco.lua index 9d2991bf..3517344d 100644 --- a/plugins/mod_disco.lua +++ b/plugins/mod_disco.lua @@ -6,13 +6,12 @@ -- COPYING file in the source package for more information. -- -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 get_children = require "prosody.core.hostmanager".get_children; +local is_contact_subscribed = require "prosody.core.rostermanager".is_contact_subscribed; +local jid_split = require "prosody.util.jid".split; +local jid_bare = require "prosody.util.jid".bare; +local st = require "prosody.util.stanza" +local calculate_hash = require "prosody.util.caps".calculate_hash; local expose_admins = module:get_option_boolean("disco_expose_admins", false); @@ -162,14 +161,16 @@ module:hook("s2s-stream-features", function (event) end end); +module:default_permission("prosody:admin", ":be-discovered-admin"); + -- 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; - 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 + local target_is_admin = module:may(":be-discovered-admin", stanza.attr.to or origin.full_jid); + if not stanza.attr.to or (expose_admins and target_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}); reply:tag("feature", { var = "http://jabber.org/protocol/disco#info" }):up(); @@ -187,7 +188,7 @@ 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 - if is_admin then + if target_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(); diff --git a/plugins/mod_external_services.lua b/plugins/mod_external_services.lua index ae418fd8..ade1e327 100644 --- a/plugins/mod_external_services.lua +++ b/plugins/mod_external_services.lua @@ -1,22 +1,22 @@ -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 dt = require "prosody.util.datetime"; +local base64 = require "prosody.util.encodings".base64; +local hashes = require "prosody.util.hashes"; +local st = require "prosody.util.stanza"; +local jid = require "prosody.util.jid"; +local array = require "prosody.util.array"; +local set = require "prosody.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_port = module:get_option_integer("external_service_port", nil, 1, 65535); local default_secret = module:get_option_string("external_service_secret"); -local default_ttl = module:get_option_number("external_service_ttl", 86400); +local default_ttl = module:get_option_period("external_service_ttl", "1 day"); 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 +-- https://datatracker.ietf.org/doc/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 diff --git a/plugins/mod_groups.lua b/plugins/mod_groups.lua index 0c44f481..1a31c51f 100644 --- a/plugins/mod_groups.lua +++ b/plugins/mod_groups.lua @@ -10,8 +10,8 @@ local groups; local members; -local datamanager = require "util.datamanager"; -local jid_prep = require "util.jid".prep; +local datamanager = require "prosody.util.datamanager"; +local jid_prep = require "prosody.util.jid".prep; local module_host = module:get_host(); diff --git a/plugins/mod_http.lua b/plugins/mod_http.lua index 0cee26c4..c13a2363 100644 --- a/plugins/mod_http.lua +++ b/plugins/mod_http.lua @@ -11,24 +11,26 @@ pcall(function () module:depends("http_errors"); end); -local portmanager = require "core.portmanager"; -local moduleapi = require "core.moduleapi"; +local portmanager = require "prosody.core.portmanager"; +local moduleapi = require "prosody.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 http_util = require "prosody.util.http"; +local normalize_path = http_util.normalize_path; +local set = require "prosody.util.set"; +local array = require "prosody.util.array"; -local ip_util = require "util.ip"; +local ip_util = require "prosody.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"; +local server = require "prosody.net.http.server"; 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")); +server.set_option("body_size_limit", module:get_option_number("http_max_content_size", nil, 0)); +server.set_option("buffer_size_limit", module:get_option_number("http_max_buffer_size", nil, 0)); -- CORS settings local cors_overrides = module:get_option("http_cors_override", {}); @@ -36,7 +38,7 @@ local opt_methods = module:get_option_set("access_control_allow_methods", { "GET local opt_headers = module:get_option_set("access_control_allow_headers", { "Content-Type" }); local opt_origins = module:get_option_set("access_control_allow_origins"); 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 opt_max_age = module:get_option_period("access_control_max_age", "2 hours"); local opt_default_cors = module:get_option_boolean("http_default_cors_enabled", true); local function get_http_event(host, app_path, key) @@ -75,11 +77,12 @@ end 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) +function moduleapi.http_url(module, app_name, default_path, mode) app_name = app_name or (module.name:gsub("^http_", "")); local external_url = url_parse(module:get_option_string("http_external_url")); - if external_url then + if external_url and mode ~= "internal" then + -- Current URL does not depend on knowing which ports are used, only configuration. local url = { scheme = external_url.scheme; host = external_url.host; @@ -91,6 +94,36 @@ function moduleapi.http_url(module, app_name, default_path) return url_build(url); end + if prosody.process_type ~= "prosody" then + -- We generally don't open ports outside of Prosody, so we can't rely on + -- portmanager to tell us which ports and services are used and derive the + -- URL from that, so instead we derive it entirely from configuration. + local https_ports = module:get_option_array("https_ports", { 5281 }); + local scheme = "https"; + local port = tonumber(https_ports[1]); + if not port then + -- https is disabled and no http_external_url set + scheme = "http"; + local http_ports = module:get_option_array("http_ports", { 5280 }); + port = tonumber(http_ports[1]); + if not port then + return "http://disabled.invalid/"; + end + end + + local url = { + scheme = scheme; + host = module:get_option_string("http_host", module.global and module:get_option_string("http_default_host") or 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 + + -- Use portmanager to find the actual port of https or http services 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 @@ -112,12 +145,16 @@ function moduleapi.http_url(module, app_name, default_path) return "http://disabled.invalid/"; end +local function header_set_tostring(header_value) + return array(header_value:items()):concat(", "); +end + local function apply_cors_headers(response, methods, headers, max_age, allow_credentials, allowed_origins, origin) if allowed_origins and not allowed_origins[origin] then return; end - response.headers.access_control_allow_methods = tostring(methods); - response.headers.access_control_allow_headers = tostring(headers); + response.headers.access_control_allow_methods = header_set_tostring(methods); + response.headers.access_control_allow_headers = header_set_tostring(headers); response.headers.access_control_max_age = tostring(max_age) response.headers.access_control_allow_origin = origin or "*"; if allow_credentials then @@ -292,7 +329,13 @@ 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; +--- deal with [ipv6]:port / ip:port format +local function normal_ip(ip) + return ip:match("^%[([%x:]*)%]") or ip:match("^([%d.]+)") or ip; +end + local function is_trusted_proxy(ip) + ip = normal_ip(ip); if trusted_proxies[ip] then return true; end @@ -308,6 +351,30 @@ 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 = http_util.parse_forwarded(request.headers.forwarded); + if forwarded then + request.forwarded = forwarded; + for i = #forwarded, 1, -1 do + local proxy = forwarded[i] + if is_trusted_proxy(ip) then + ip = normal_ip(proxy["for"]); + secure = secure and proxy.proto == "https"; + else + break + end + end + end + + return ip, secure; +end + +-- TODO switch to RFC 7239 by default once support is more common +if module:get_option_boolean("http_legacy_x_forwarded", true) then +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 then -- luacheck: ignore 631 @@ -330,6 +397,7 @@ local function get_forwarded_connection_info(request) --> ip:string, secure:bool return ip, secure; end +end module:wrap_object_event(server._events, false, function (handlers, event_name, event_data) local request = event_data.request; diff --git a/plugins/mod_http_errors.lua b/plugins/mod_http_errors.lua index ec54860c..c92e44ce 100644 --- a/plugins/mod_http_errors.lua +++ b/plugins/mod_http_errors.lua @@ -1,9 +1,9 @@ module:set_global(); -local server = require "net.http.server"; -local codes = require "net.http.codes"; -local xml_escape = require "util.stanza".xml_escape; -local render = require "util.interpolation".new("%b{}", xml_escape); +local server = require "prosody.net.http.server"; +local codes = require "prosody.net.http.codes"; +local xml_escape = require "prosody.util.stanza".xml_escape; +local render = require "prosody.util.interpolation".new("%b{}", xml_escape); local show_private = module:get_option_boolean("http_errors_detailed", false); local always_serve = module:get_option_boolean("http_errors_always_show", true); @@ -35,13 +35,13 @@ local html = [[ <meta charset="utf-8"> <title>{title}</title> <style> -body{margin-top:14%;text-align:center;background-color:#f8f8f8;font-family:sans-serif} +:root{color-scheme:light dark} +body{margin-top:14%;text-align:center;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> diff --git a/plugins/mod_http_file_share.lua b/plugins/mod_http_file_share.lua index b6200628..cfc647d4 100644 --- a/plugins/mod_http_file_share.lua +++ b/plugins/mod_http_file_share.lua @@ -8,17 +8,16 @@ -- Again, from the top! local t_insert = table.insert; -local jid = require "util.jid"; -local st = require "util.stanza"; +local jid = require "prosody.util.jid"; +local st = require "prosody.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 urlencode = require "util.http".urlencode; -local dt = require "util.datetime"; -local hi = require "util.human.units"; -local cache = require "util.cache"; +local dm = require "prosody.core.storagemanager".olddm; +local errors = require "prosody.util.error"; +local dataform = require "prosody.util.dataforms".new; +local urlencode = require "prosody.util.http".urlencode; +local dt = require "prosody.util.datetime"; +local hi = require "prosody.util.human.units"; +local cache = require "prosody.util.cache"; local lfs = require "lfs"; local unknown = math.abs(0/0); @@ -35,17 +34,21 @@ 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 secret = module:get_option_string(module.name.."_secret", require"prosody.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_size_limit = module:get_option_integer(module.name .. "_size_limit", 10 * 1024 * 1024, 0); -- 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 expiry = module:get_option_period(module.name .. "_expires_after", "1w"); +local daily_quota = module:get_option_integer(module.name .. "_daily_quota", file_size_limit*10, 0); -- 100 MB / day +local total_storage_limit = module:get_option_integer(module.name.."_global_quota", unlimited, 0); + +local create_jwt, verify_jwt = require"prosody.util.jwt".init("HS256", secret, secret, { default_ttl = 600 }); local access = module:get_option_set(module.name .. "_access", {}); +module:default_permission("prosody:registered", ":upload"); + if not external_base_url then module:depends("http"); end @@ -76,12 +79,12 @@ 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 +module:on_ready(function () local total, err = persist_stats:get(nil, "total"); if not err then total_storage_usage = tonumber(total) or 0; end -end +end) module:hook_global("stats-update", function () measure_upload_cache_size(upload_cache:count()); @@ -135,7 +138,7 @@ 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 + if not (module:may(":upload", uploader) or access:contains(uploader) or access:contains(uploader_host)) then return false, upload_errors.new("access"); end @@ -169,16 +172,13 @@ function may_upload(uploader, filename, filesize, filetype) -- > boolean, error end function get_authz(slot, uploader, filename, filesize, filetype) -local now = os.time(); - return jwt.sign(secret, { + return create_jwt({ -- token properties sub = uploader; - iat = now; - exp = now+300; -- slot properties slot = slot; - expires = expiry >= 0 and (now+expiry) or nil; + expires = expiry < math.huge and (os.time()+expiry) or nil; -- file properties filename = filename; filesize = filesize; @@ -249,32 +249,34 @@ 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. + local upload_info = request.http_file_share_upload_info; + + if not upload_info then -- Initial handling of 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, authed_upload_info = verify_jwt(authz); + if not authed then + module:log("debug", "Unauthorized or invalid token: %s, %q", authz, authed_upload_info); + return 401; + end + if not path or authed_upload_info.slot ~= path:match("^[^/]+") then + module:log("debug", "Invalid upload slot: %q, path: %q", authed_upload_info.slot, path); + return 400; + end + if request.headers.content_length and tonumber(request.headers.content_length) ~= authed_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 + upload_info = authed_upload_info; + request.http_file_share_upload_info = upload_info; end local filename = get_filename(upload_info.slot, true); @@ -450,11 +452,11 @@ function handle_download(event, path) -- GET /uploads/:slot+filename return response:send_file(handle); end -if expiry >= 0 and not external_base_url then +if expiry < math.huge 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 array = require "prosody.util.array"; + local async = require "prosody.util.async"; + local ENOENT = require "prosody.util.pposix".ENOENT; local function sleep(t) local wait, done = async.waiter(); diff --git a/plugins/mod_http_files.lua b/plugins/mod_http_files.lua index b921116a..799fb9c8 100644 --- a/plugins/mod_http_files.lua +++ b/plugins/mod_http_files.lua @@ -9,11 +9,11 @@ module:depends("http"); local open = io.open; -local fileserver = require"net.http.files"; +local fileserver = require"prosody.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); -local cache_max_file_size = module:get_option_number("http_files_cache_max_file_size", 4096); +local cache_size = module:get_option_integer("http_files_cache_size", 128, 1); +local cache_max_file_size = module:get_option_integer("http_files_cache_max_file_size", 4096, 1); local dir_indices = module:get_option_array("http_index_files", { "index.html", "index.htm" }); local directory_index = module:get_option_boolean("http_dir_listing"); @@ -74,12 +74,12 @@ function serve(opts) if opts.index_files == nil then opts.index_files = dir_indices; end - module:log("warn", "%s should be updated to use 'net.http.files' instead of mod_http_files", get_calling_module()); + module:log("warn", "%s should be updated to use 'prosody.net.http.files' instead 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' instead of mod_http_files", get_calling_module()); + module:log("debug", "%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module()); for route,handler in pairs(routes) do if type(handler) ~= "function" then routes[route] = fileserver.serve(handler); diff --git a/plugins/mod_http_openmetrics.lua b/plugins/mod_http_openmetrics.lua index 0c204ff4..5f151521 100644 --- a/plugins/mod_http_openmetrics.lua +++ b/plugins/mod_http_openmetrics.lua @@ -8,8 +8,8 @@ module:set_global(); -local statsman = require "core.statsmanager"; -local ip = require "util.ip"; +local statsman = require "prosody.core.statsmanager"; +local ip = require "prosody.util.ip"; local get_metric_registry = statsman.get_metric_registry; local collect = statsman.collect; diff --git a/plugins/mod_invites.lua b/plugins/mod_invites.lua index 88690f7e..5ee9430a 100644 --- a/plugins/mod_invites.lua +++ b/plugins/mod_invites.lua @@ -1,10 +1,12 @@ -local id = require "util.id"; -local it = require "util.iterators"; +local id = require "prosody.util.id"; +local it = require "prosody.util.iterators"; local url = require "socket.url"; -local jid_node = require "util.jid".node; -local jid_split = require "util.jid".split; +local jid_node = require "prosody.util.jid".node; +local jid_split = require "prosody.util.jid".split; +local argparse = require "prosody.util.argparse"; +local human_io = require "prosody.util.human.io"; -local default_ttl = module:get_option_number("invite_expiry", 86400 * 7); +local default_ttl = module:get_option_period("invite_expiry", "1 week"); local token_storage; if prosody.process_type == "prosody" or prosody.shutdown then @@ -201,53 +203,103 @@ function use(token) --luacheck: ignore 131/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); +module:add_item("shell-command", { + section = "invite"; + section_desc = "Create and manage invitations"; + name = "create_account"; + desc = "Create an invitation to make an account on this server with the specified JID (supply only a hostname to allow any username)"; + args = { { name = "user_jid", type = "string" } }; + host_selector = "user_jid"; + + handler = function (self, user_jid) --luacheck: ignore 212/self + local username = jid_split(user_jid); + local invite, err = create_account(username); if not invite then return nil, err; end return true, invite.landing_page or 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); + end; +}); + +module:add_item("shell-command", { + section = "invite"; + section_desc = "Create and manage invitations"; + name = "create_contact"; + desc = "Create an invitation to become contacts with the specified user"; + args = { { name = "user_jid", type = "string" }, { name = "allow_registration" } }; + host_selector = "user_jid"; + + handler = function (self, user_jid, allow_registration) --luacheck: ignore 212/self + local username = jid_split(user_jid); + local invite, err = create_contact(username, allow_registration); if not invite then return nil, err; end return true, invite.landing_page or invite.uri; - end -end + end; +}); + +local subcommands = {}; --- prosodyctl command function module.command(arg) - if #arg < 2 or arg[1] ~= "generate" then + local opts = argparse.parse(arg, { short_params = { h = "help"; ["?"] = "help" } }); + local cmd = table.remove(arg, 1); -- pop command + if opts.help or not cmd or not subcommands[cmd] then print("usage: prosodyctl mod_"..module.name.." generate example.com"); return 2; end - table.remove(arg, 1); -- pop command + return subcommands[cmd](arg); +end - local sm = require "core.storagemanager"; - local mm = require "core.modulemanager"; +function subcommands.generate(arg) + local function help(short) + print("usage: prosodyctl mod_" .. module.name .. " generate DOMAIN --reset USERNAME") + print("usage: prosodyctl mod_" .. module.name .. " generate DOMAIN [--admin] [--role ROLE] [--group GROUPID]...") + if short then return 2 end + 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(" --expires-after T Time until the invite expires (e.g. '1 week')") + print() + 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 + end - local host = arg[1]; - assert(prosody.hosts[host], "Host "..tostring(host).." does not exist"); + local earlyopts = argparse.parse(arg, { short_params = { h = "help"; ["?"] = "help" } }); + if earlyopts.help or not earlyopts[1] then + return help(); + end + + local sm = require "prosody.core.storagemanager"; + local mm = require "prosody.core.modulemanager"; + + local host = table.remove(arg, 1); -- pop host + if not host then return help(true) end 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"); + local opts = argparse.parse(arg, { + short_params = { h = "help"; ["?"] = "help"; g = "group" }; + value_params = { group = true; reset = true; role = true }; + array_params = { group = true; role = true }; + }); + + if opts.help then + return help(); + end + -- Load mod_invites local invites = module:depends("invites"); -- Optional community module that if used, needs to be loaded here @@ -257,71 +309,28 @@ function module.command(arg) 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) + + if opts.reset then + local nodeprep = require "prosody.util.encodings".stringprep.nodeprep; + local username = nodeprep(opts.reset) + if not username then + print("Please supply a valid username to generate a reset link for"); + return 2; end + allow_reset = username; + end + + local roles = opts.role or {}; + local groups = opts.groups or {}; + + if opts.admin then + -- Insert it first since we don't get order out of argparse + table.insert(roles, 1, "prosody:admin"); end local invite; if allow_reset then - if roles then + if roles[1] then print("--role/--admin and --reset are mutually exclusive") return 2; end @@ -333,7 +342,7 @@ function module.command(arg) invite = assert(invites.create_account(nil, { roles = roles, groups = groups - })); + }, opts.expires_after and human_io.parse_duration(opts.expires_after))); end print(invite.landing_page or invite.uri); diff --git a/plugins/mod_invites_adhoc.lua b/plugins/mod_invites_adhoc.lua index 9c0660e9..c9954d8c 100644 --- a/plugins/mod_invites_adhoc.lua +++ b/plugins/mod_invites_adhoc.lua @@ -1,8 +1,7 @@ -- 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 dataforms = require "prosody.util.dataforms"; +local datetime = require "prosody.util.datetime"; +local split_jid = require "prosody.util.jid".split; local new_adhoc = module:require("adhoc").new; @@ -13,8 +12,7 @@ local allow_user_invites = module:get_option_boolean("allow_user_invites", false -- 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"); +module:default_permission(allow_user_invites and "prosody:registered" or "prosody:admin", ":invite-users"); local invites; if prosody.shutdown then -- COMPAT hack to detect prosodyctl @@ -42,36 +40,8 @@ local invite_result_form = dataforms.new({ -- 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 - -- User has no roles we can check, just return default - return allow_user_invites; - 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; +local function may_invite_new_users(context) + return module:may(":invite-users", context); end module:depends("adhoc"); @@ -91,7 +61,7 @@ module:provides("adhoc", new_adhoc("Create new contact invite", "urn:xmpp:invite }; }; end - local invite = invites.create_contact(username, may_invite_new_users(data.from), { + local invite = invites.create_contact(username, may_invite_new_users(data), { source = data.from }); --TODO: check errors diff --git a/plugins/mod_invites_register.lua b/plugins/mod_invites_register.lua index d1d801ad..d9274ce4 100644 --- a/plugins/mod_invites_register.lua +++ b/plugins/mod_invites_register.lua @@ -1,7 +1,7 @@ -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 st = require "prosody.util.stanza"; +local jid_split = require "prosody.util.jid".split; +local jid_bare = require "prosody.util.jid".bare; +local rostermanager = require "prosody.core.rostermanager"; local require_encryption = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true)); @@ -147,7 +147,20 @@ module:hook("user-registered", function (event) if validated_invite.additional_data then module:log("debug", "Importing roles from invite"); local roles = validated_invite.additional_data.roles; - if roles then + if roles and roles[1] ~= nil then + local um = require "prosody.core.usermanager"; + local ok, err = um.set_user_role(event.username, module.host, roles[1]); + if not ok then + module:log("error", "Could not set role %s for newly registered user %s: %s", roles[1], event.username, err); + end + for i = 2, #roles do + local ok, err = um.add_user_secondary_role(event.username, module.host, roles[i]); + if not ok then + module:log("warn", "Could not add secondary role %s for newly registered user %s: %s", roles[i], event.username, err); + end + end + elseif roles and type(next(roles)) == "string" then + module:log("warn", "Invite carries legacy, migration required for user '%s' for role set %q to take effect", event.username, roles); module:open_store("roles"):set(contact_username, roles); end end diff --git a/plugins/mod_iq.lua b/plugins/mod_iq.lua index 87c3a467..77969147 100644 --- a/plugins/mod_iq.lua +++ b/plugins/mod_iq.lua @@ -7,7 +7,7 @@ -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local full_sessions = prosody.full_sessions; diff --git a/plugins/mod_lastactivity.lua b/plugins/mod_lastactivity.lua index 91d11bd2..e41bc02a 100644 --- a/plugins/mod_lastactivity.lua +++ b/plugins/mod_lastactivity.lua @@ -6,10 +6,10 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; -local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; -local jid_bare = require "util.jid".bare; -local jid_split = require "util.jid".split; +local st = require "prosody.util.stanza"; +local is_contact_subscribed = require "prosody.core.rostermanager".is_contact_subscribed; +local jid_bare = require "prosody.util.jid".bare; +local jid_split = require "prosody.util.jid".split; module:add_feature("jabber:iq:last"); diff --git a/plugins/mod_legacyauth.lua b/plugins/mod_legacyauth.lua index 52f2c143..048cd3e1 100644 --- a/plugins/mod_legacyauth.lua +++ b/plugins/mod_legacyauth.lua @@ -8,17 +8,17 @@ -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local t_concat = table.concat; local secure_auth_only = module:get_option("c2s_require_encryption", module:get_option("require_encryption", true)) or not(module:get_option("allow_unencrypted_plain_auth")); -local sessionmanager = require "core.sessionmanager"; -local usermanager = require "core.usermanager"; -local nodeprep = require "util.encodings".stringprep.nodeprep; -local resourceprep = require "util.encodings".stringprep.resourceprep; +local sessionmanager = require "prosody.core.sessionmanager"; +local usermanager = require "prosody.core.usermanager"; +local nodeprep = require "prosody.util.encodings".stringprep.nodeprep; +local resourceprep = require "prosody.util.encodings".stringprep.resourceprep; module:add_feature("jabber:iq:auth"); module:hook("stream-features", function(event) diff --git a/plugins/mod_limits.lua b/plugins/mod_limits.lua index 4f1b618e..407f681f 100644 --- a/plugins/mod_limits.lua +++ b/plugins/mod_limits.lua @@ -1,13 +1,13 @@ -- Because we deal with pre-authed sessions and streams we can't be host-specific module:set_global(); -local filters = require "util.filters"; -local throttle = require "util.throttle"; -local timer = require "util.timer"; +local filters = require "prosody.util.filters"; +local throttle = require "prosody.util.throttle"; +local timer = require "prosody.util.timer"; local ceil = math.ceil; local limits_cfg = module:get_option("limits", {}); -local limits_resolution = module:get_option_number("limits_resolution", 1); +local limits_resolution = module:get_option_period("limits_resolution", 1); local default_bytes_per_second = 3000; local default_burst = 2; diff --git a/plugins/mod_mam/mamprefs.lib.lua b/plugins/mod_mam/mamprefs.lib.lua index dd82b626..cddcbd30 100644 --- a/plugins/mod_mam/mamprefs.lib.lua +++ b/plugins/mod_mam/mamprefs.lib.lua @@ -10,12 +10,15 @@ -- -- luacheck: ignore 122/prosody -local global_default_policy = module:get_option_string("default_archive_policy", true); -if global_default_policy ~= "roster" then - global_default_policy = module:get_option_boolean("default_archive_policy", global_default_policy); -end +local global_default_policy = module:get_option_enum("default_archive_policy", "always", "roster", "never", true, false); local smart_enable = module:get_option_boolean("mam_smart_enable", false); +if global_default_policy == "always" then + global_default_policy = true; +elseif global_default_policy == "never" then + global_default_policy = false; +end + do -- luacheck: ignore 211/prefs_format local prefs_format = { diff --git a/plugins/mod_mam/mamprefsxml.lib.lua b/plugins/mod_mam/mamprefsxml.lib.lua index c408fbea..b325e886 100644 --- a/plugins/mod_mam/mamprefsxml.lib.lua +++ b/plugins/mod_mam/mamprefsxml.lib.lua @@ -10,8 +10,8 @@ -- XEP-0313: Message Archive Management for Prosody -- -local st = require"util.stanza"; -local jid_prep = require"util.jid".prep; +local st = require"prosody.util.stanza"; +local jid_prep = require"prosody.util.jid".prep; local xmlns_mam = "urn:xmpp:mam:2"; local default_attrs = { diff --git a/plugins/mod_mam/mod_mam.lua b/plugins/mod_mam/mod_mam.lua index bebee812..b57fc030 100644 --- a/plugins/mod_mam/mod_mam.lua +++ b/plugins/mod_mam/mod_mam.lua @@ -15,36 +15,36 @@ local xmlns_delay = "urn:xmpp:delay"; local xmlns_forward = "urn:xmpp:forward:0"; local xmlns_st_id = "urn:xmpp:sid:0"; -local um = require "core.usermanager"; -local st = require "util.stanza"; -local rsm = require "util.rsm"; +local um = require "prosody.core.usermanager"; +local st = require "prosody.util.stanza"; +local rsm = require "prosody.util.rsm"; local get_prefs = module:require"mamprefs".get; local set_prefs = module:require"mamprefs".set; 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 jid_bare = require "prosody.util.jid".bare; +local jid_split = require "prosody.util.jid".split; +local jid_resource = require "prosody.util.jid".resource; +local jid_prepped_split = require "prosody.util.jid".prepped_split; +local dataform = require "prosody.util.dataforms".new; +local get_form_type = require "prosody.util.dataforms".get_type; local host = module.host; -local rm_load_roster = require "core.rostermanager".load_roster; +local rm_load_roster = require "prosody.core.rostermanager".load_roster; local is_stanza = st.is_stanza; local tostring = tostring; -local time_now = os.time; +local time_now = require "prosody.util.time".now; local m_min = math.min; 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 default_max_items, max_max_items = 20, module:get_option_integer("max_archive_query_results", 50, 0); 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 cleanup_after = module:get_option_period("archive_expires_after", "1w"); +local archive_item_limit = module:get_option_integer("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000, 0); local archive_truncate = math.floor(archive_item_limit * 0.99); if not archive.find then @@ -53,8 +53,12 @@ if not archive.find then end local use_total = module:get_option_boolean("mam_include_total", true); -function schedule_cleanup() - -- replaced later if cleanup is enabled +function schedule_cleanup(_username, _date) -- luacheck: ignore 212 + -- Called to make a note of which users have messages on which days, which in + -- turn is used to optimize the message expiry routine. + -- + -- This noop is conditionally replaced later depending on retention settings + -- and storage backend capabilities. end -- Handle prefs. @@ -245,8 +249,7 @@ module:hook("iq-get/self/"..xmlns_mam..":metadata", function (event) return true; end - local id, _, when = first(); - if id then + for id, _, when in first do reply:tag("start", { id = id, timestamp = timestamp(when) }):up(); end end @@ -258,8 +261,7 @@ module:hook("iq-get/self/"..xmlns_mam..":metadata", function (event) return true; end - local id, _, when = last(); - if id then + for id, _, when in last do reply:tag("end", { id = id, timestamp = timestamp(when) }):up(); end end @@ -437,7 +439,7 @@ local function message_handler(event, c2s) 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 + if cleanup_after ~= math.huge then module:log("debug", "User '%s' over quota, cleaning archive", store_user); local cleaned = archive:delete(store_user, { ["end"] = (os.time() - cleanup_after); @@ -502,20 +504,10 @@ module:hook("message/offline/broadcast", function (event) end end); -if cleanup_after ~= "never" then +if cleanup_after ~= math.huge then local cleanup_storage = module:open_store("archive_cleanup"); local cleanup_map = module:open_store("archive_cleanup", "map"); - local day = 86400; - local multipliers = { d = day, w = day * 7, m = 31 * day, y = 365.2425 * day }; - local n, m = cleanup_after:lower():match("(%d+)%s*([dwmy]?)"); - if not n then - module:log("error", "Could not parse archive_expires_after string %q", cleanup_after); - return false; - end - - cleanup_after = tonumber(n) * ( multipliers[m] or 1 ); - module:log("debug", "archive_expires_after = %d -- in seconds", cleanup_after); if not archive.delete then @@ -528,7 +520,7 @@ if cleanup_after ~= "never" then -- outside the cleanup range. 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)); + local last_date = require "prosody.util.cache".new(module:get_option_integer("archive_cleanup_date_cache_size", 1000, 1)); function schedule_cleanup(username, date) date = date or datestamp(); if last_date:get(username) == date then return end @@ -541,7 +533,7 @@ if cleanup_after ~= "never" then local cleanup_time = module:measure("cleanup", "times"); - local async = require "util.async"; + local async = require "prosody.util.async"; module:daily("Remove expired messages", function () local cleanup_done = cleanup_time(); diff --git a/plugins/mod_message.lua b/plugins/mod_message.lua index 9c07e796..aa9f5c2d 100644 --- a/plugins/mod_message.lua +++ b/plugins/mod_message.lua @@ -10,10 +10,10 @@ local full_sessions = prosody.full_sessions; local bare_sessions = prosody.bare_sessions; -local st = require "util.stanza"; -local jid_bare = require "util.jid".bare; -local jid_split = require "util.jid".split; -local user_exists = require "core.usermanager".user_exists; +local st = require "prosody.util.stanza"; +local jid_bare = require "prosody.util.jid".bare; +local jid_split = require "prosody.util.jid".split; +local user_exists = require "prosody.core.usermanager".user_exists; local function process_to_bare(bare, origin, stanza) local user = bare_sessions[bare]; diff --git a/plugins/mod_mimicking.lua b/plugins/mod_mimicking.lua index ab7612cb..52a070f5 100644 --- a/plugins/mod_mimicking.lua +++ b/plugins/mod_mimicking.lua @@ -6,13 +6,13 @@ -- COPYING file in the source package for more information. -- -local encodings = require "util.encodings"; +local encodings = require "prosody.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 usage = require "prosody.util.prosodyctl".show_usage; +local usermanager = require "prosody.core.usermanager"; +local storagemanager = require "prosody.core.storagemanager"; local skeletons function module.load() diff --git a/plugins/mod_motd.lua b/plugins/mod_motd.lua index 47e64be3..bee0820c 100644 --- a/plugins/mod_motd.lua +++ b/plugins/mod_motd.lua @@ -13,7 +13,7 @@ local motd_jid = module:get_option_string("motd_jid", host); if not motd_text then return; end -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; motd_text = motd_text:gsub("^%s*(.-)%s*$", "%1"):gsub("\n[ \t]+", "\n"); -- Strip indentation from the config diff --git a/plugins/mod_muc_mam.lua b/plugins/mod_muc_mam.lua index 0918b95d..23bb7dab 100644 --- a/plugins/mod_muc_mam.lua +++ b/plugins/mod_muc_mam.lua @@ -16,28 +16,28 @@ local xmlns_st_id = "urn:xmpp:sid:0"; local xmlns_muc_user = "http://jabber.org/protocol/muc#user"; local muc_form_enable = "muc#roomconfig_enablearchiving" -local st = require "util.stanza"; -local rsm = require "util.rsm"; -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 st = require "prosody.util.stanza"; +local rsm = require "prosody.util.rsm"; +local jid_bare = require "prosody.util.jid".bare; +local jid_split = require "prosody.util.jid".split; +local jid_prep = require "prosody.util.jid".prep; +local dataform = require "prosody.util.dataforms".new; +local get_form_type = require "prosody.util.dataforms".get_type; local mod_muc = module:depends"muc"; local get_room_from_jid = mod_muc.get_room_from_jid; local is_stanza = st.is_stanza; local tostring = tostring; -local time_now = os.time; +local time_now = require "prosody.util.time".now; local m_min = math.min; -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 timestamp, datestamp = import("prosody.util.datetime", "datetime", "date"); +local default_max_items, max_max_items = 20, module:get_option_integer("max_archive_query_results", 50, 0); -local cleanup_after = module:get_option_string("muc_log_expires_after", "1w"); +local cleanup_after = module:get_option_period("muc_log_expires_after", "1w"); local default_history_length = 20; -local max_history_length = module:get_option_number("max_history_messages", math.huge); +local max_history_length = module:get_option_integer("max_history_messages", math.huge, 0); local function get_historylength(room) return math.min(room._data.history_length or default_history_length, max_history_length); @@ -53,7 +53,7 @@ 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_item_limit = module:get_option_integer("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000, 0); local archive_truncate = math.floor(archive_item_limit * 0.99); if archive.name == "null" or not archive.find then @@ -397,7 +397,7 @@ local function save_to_history(self, stanza) 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 + if cleanup_after ~= math.huge then module:log("debug", "Room '%s' over quota, cleaning archive", room_node); local cleaned = archive:delete(room_node, { ["end"] = (os.time() - cleanup_after); @@ -467,20 +467,10 @@ end); -- Cleanup -if cleanup_after ~= "never" then +if cleanup_after ~= math.huge then local cleanup_storage = module:open_store("muc_log_cleanup"); local cleanup_map = module:open_store("muc_log_cleanup", "map"); - local day = 86400; - local multipliers = { d = day, w = day * 7, m = 31 * day, y = 365.2425 * day }; - local n, m = cleanup_after:lower():match("(%d+)%s*([dwmy]?)"); - if not n then - module:log("error", "Could not parse muc_log_expires_after string %q", cleanup_after); - return false; - end - - cleanup_after = tonumber(n) * ( multipliers[m] or 1 ); - module:log("debug", "muc_log_expires_after = %d -- in seconds", cleanup_after); if not archive.delete then @@ -492,7 +482,7 @@ if cleanup_after ~= "never" then -- messages, we collect the union of sets of rooms from dates that fall -- outside the cleanup range. - local last_date = require "util.cache".new(module:get_option_number("muc_log_cleanup_date_cache_size", 1000)); + local last_date = require "prosody.util.cache".new(module:get_option_integer("muc_log_cleanup_date_cache_size", 1000, 1)); if not ( archive.caps and archive.caps.wildcard_delete ) then function schedule_cleanup(roomname, date) date = date or datestamp(); @@ -506,7 +496,7 @@ if cleanup_after ~= "never" then local cleanup_time = module:measure("cleanup", "times"); - local async = require "util.async"; + local async = require "prosody.util.async"; module:daily("Remove expired messages", function () local cleanup_done = cleanup_time(); diff --git a/plugins/mod_muc_unique.lua b/plugins/mod_muc_unique.lua index 13284745..62ec74b8 100644 --- a/plugins/mod_muc_unique.lua +++ b/plugins/mod_muc_unique.lua @@ -1,6 +1,6 @@ -- XEP-0307: Unique Room Names for Multi-User Chat -local st = require "util.stanza"; -local unique_name = require "util.id".medium; +local st = require "prosody.util.stanza"; +local unique_name = require "prosody.util.id".medium; module:add_feature "http://jabber.org/protocol/muc#unique" module:hook("iq-get/host/http://jabber.org/protocol/muc#unique:unique", function(event) local origin, stanza = event.origin, event.stanza; diff --git a/plugins/mod_net_multiplex.lua b/plugins/mod_net_multiplex.lua index ddd58463..3f5ee54d 100644 --- a/plugins/mod_net_multiplex.lua +++ b/plugins/mod_net_multiplex.lua @@ -1,10 +1,10 @@ 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 array = require "prosody.util.array"; +local max_buffer_len = module:get_option_integer("multiplex_buffer_size", 1024, 1); +local default_mode = module:get_option_integer("network_default_read_size", 4096, 0); -local portmanager = require "core.portmanager"; +local portmanager = require "prosody.core.portmanager"; local available_services = {}; local service_by_protocol = {}; diff --git a/plugins/mod_offline.lua b/plugins/mod_offline.lua index dffe8357..b71bbfd9 100644 --- a/plugins/mod_offline.lua +++ b/plugins/mod_offline.lua @@ -7,8 +7,8 @@ -- -local datetime = require "util.datetime"; -local jid_split = require "util.jid".split; +local datetime = require "prosody.util.datetime"; +local jid_split = require "prosody.util.jid".split; local offline_messages = module:open_store("offline", "archive"); diff --git a/plugins/mod_pep.lua b/plugins/mod_pep.lua index 71e45e7c..33eee2ec 100644 --- a/plugins/mod_pep.lua +++ b/plugins/mod_pep.lua @@ -1,21 +1,23 @@ -local pubsub = require "util.pubsub"; -local jid_bare = require "util.jid".bare; -local jid_split = require "util.jid".split; -local jid_join = require "util.jid".join; -local set_new = require "util.set".new; -local st = require "util.stanza"; -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"; +local pubsub = require "prosody.util.pubsub"; +local jid_bare = require "prosody.util.jid".bare; +local jid_split = require "prosody.util.jid".split; +local jid_join = require "prosody.util.jid".join; +local set_new = require "prosody.util.set".new; +local st = require "prosody.util.stanza"; +local calculate_hash = require "prosody.util.caps".calculate_hash; +local rostermanager = require "prosody.core.rostermanager"; +local cache = require "prosody.util.cache"; +local set = require "prosody.util.set"; +local new_id = require "prosody.util.id".medium; +local storagemanager = require "prosody.core.storagemanager"; +local usermanager = require "prosody.core.usermanager"; local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event"; local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner"; +local is_contact_subscribed = rostermanager.is_contact_subscribed; + local lib_pubsub = module:require "pubsub"; local empty_set = set_new(); @@ -24,7 +26,7 @@ local empty_set = set_new(); local pep_service_items = {}; -- size of caches with full pubsub service objects -local service_cache_size = module:get_option_number("pep_service_cache_size", 1000); +local service_cache_size = module:get_option_integer("pep_service_cache_size", 1000, 1); -- username -> util.pubsub service object local services = cache.new(service_cache_size, function (username, _) @@ -36,7 +38,7 @@ local services = cache.new(service_cache_size, function (username, _) end):table(); -- size of caches with smaller objects -local info_cache_size = module:get_option_number("pep_info_cache_size", 10000); +local info_cache_size = module:get_option_integer("pep_info_cache_size", 10000, 1); -- username -> recipient -> set of nodes local recipients = cache.new(info_cache_size):table(); @@ -49,7 +51,7 @@ local host = module.host; local node_config = module:open_store("pep", "map"); local known_nodes = module:open_store("pep"); -local max_max_items = module:get_option_number("pep_max_items", 256); +local max_max_items = module:get_option_number("pep_max_items", 256, 0); local function tonumber_max_items(n) if n == "max" then @@ -84,6 +86,7 @@ function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node return false; end if new_config["access_model"] ~= "presence" + and new_config["access_model"] ~= "roster" and new_config["access_model"] ~= "whitelist" and new_config["access_model"] ~= "open" then return false; @@ -136,10 +139,14 @@ end local function get_broadcaster(username) local user_bare = jid_join(username, host); local function simple_broadcast(kind, node, jids, item, _, node_obj) + local expose_publisher; if node_obj then if node_obj.config["notify_"..kind] == false then return; end + if node_obj.config.itemreply == "publisher" then + expose_publisher = true; + end end if kind == "retract" then kind = "items"; -- XEP-0060 signals retraction in an <items> container @@ -151,6 +158,9 @@ local function get_broadcaster(username) if node_obj and node_obj.config.include_payload == false then item:maptags(function () return nil; end); end + if not expose_publisher then + item.attr.publisher = nil; + end end end @@ -249,6 +259,20 @@ function get_pep_service(username) end return "outcast"; end; + roster = function (jid, node) + jid = jid_bare(jid); + local allowed_groups = set_new(node.config.roster_groups_allowed); + local roster = rostermanager.load_roster(username, host); + if not roster[jid] then + return "outcast"; + end + for group in pairs(roster[jid].groups) do + if allowed_groups:contains(group) then + return "member"; + end + end + return "outcast"; + end; }; jid = user_bare; @@ -306,7 +330,7 @@ local function resend_last_item(jid, node, service) 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); + service.config.broadcaster("items", node, { [jid] = true }, item, true, service.nodes[node], service); end local function update_subscriptions(recipient, service_name, nodes) diff --git a/plugins/mod_pep_simple.lua b/plugins/mod_pep_simple.lua index e686b99b..a196a0ff 100644 --- a/plugins/mod_pep_simple.lua +++ b/plugins/mod_pep_simple.lua @@ -7,15 +7,15 @@ -- -local jid_bare = require "util.jid".bare; -local jid_split = require "util.jid".split; -local st = require "util.stanza"; -local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; +local jid_bare = require "prosody.util.jid".bare; +local jid_split = require "prosody.util.jid".split; +local st = require "prosody.util.stanza"; +local is_contact_subscribed = require "prosody.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 unpack = table.unpack; +local calculate_hash = require "prosody.util.caps".calculate_hash; local core_post_stanza = prosody.core_post_stanza; local bare_sessions = prosody.bare_sessions; diff --git a/plugins/mod_ping.lua b/plugins/mod_ping.lua index b6ccc928..018f815a 100644 --- a/plugins/mod_ping.lua +++ b/plugins/mod_ping.lua @@ -6,7 +6,7 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; module:add_feature("urn:xmpp:ping"); diff --git a/plugins/mod_posix.lua b/plugins/mod_posix.lua index 7f048be3..101e6e62 100644 --- a/plugins/mod_posix.lua +++ b/plugins/mod_posix.lua @@ -6,167 +6,6 @@ -- COPYING file in the source package for more information. -- - -local want_pposix_version = "0.4.0"; - -local pposix = assert(require "util.pposix"); -if pposix._VERSION ~= want_pposix_version then - module:log("warn", "Unknown version (%s) of binary pposix module, expected %s." - .. "Perhaps you need to recompile?", tostring(pposix._VERSION), want_pposix_version); -end - -local have_signal, signal = pcall(require, "util.signal"); -if not have_signal then - module:log("warn", "Couldn't load signal library, won't respond to SIGTERM"); -end - -local lfs = require "lfs"; -local stat = lfs.attributes; - -local prosody = _G.prosody; - module:set_global(); -- we're a global module -local umask = module:get_option_string("umask", "027"); -pposix.umask(umask); - --- Don't even think about it! -if not prosody.start_time then -- server-starting - 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 - -local pidfile; -local pidfile_handle; - -local function remove_pidfile() - if pidfile_handle then - pidfile_handle:close(); - os.remove(pidfile); - pidfile, pidfile_handle = nil, nil; - end -end - -local function write_pidfile() - if pidfile_handle then - remove_pidfile(); - end - pidfile = module:get_option_path("pidfile", nil, "data"); - if pidfile then - local err; - local mode = stat(pidfile) and "r+" or "w+"; - 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", 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", 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", 1); - else - if lfs.lock(pidfile_handle, "w") then - pidfile_handle:write(tostring(pposix.getpid())); - pidfile_handle:flush(); - end - end - end - end - end -end - -local daemonize = prosody.opts.daemonize; - -if daemonize == nil then - -- Fall back to config file if not specified on command-line - 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() - local lm = require "core.loggingmanager"; - lm.register_sink_type("console", nil); - lm.register_sink_type("stdout", nil); - lm.reload_logging(); -end - -if daemonize then - local function daemonize_server() - module:log("info", "Prosody is about to detach from the console, disabling further console output"); - remove_log_sinks(); - local ok, ret = pposix.daemonize(); - if not ok then - module:log("error", "Failed to daemonize: %s", ret); - elseif ret and ret > 0 then - os.exit(0); - else - module:log("info", "Successfully daemonized to PID %d", pposix.getpid()); - write_pidfile(); - end - end - module:hook("server-started", daemonize_server) -else - -- Not going to daemonize, so write the pid of this process - write_pidfile(); -end - -module:hook("server-stopped", remove_pidfile); - --- Set signal handlers -if have_signal then - module:add_timer(0, function () - signal.signal("SIGTERM", function () - module:log("warn", "Received SIGTERM"); - prosody.main_thread:run(function () - prosody.unlock_globals(); - prosody.shutdown("Received SIGTERM"); - prosody.lock_globals(); - end); - end); - - signal.signal("SIGHUP", function () - module:log("info", "Received SIGHUP"); - prosody.main_thread:run(function () - prosody.reload_config(); - end); - -- this also reloads logging - end); - - signal.signal("SIGINT", function () - module:log("info", "Received SIGINT"); - prosody.main_thread:run(function () - prosody.unlock_globals(); - prosody.shutdown("Received SIGINT"); - prosody.lock_globals(); - end); - 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; -}; +-- TODO delete this whole concept diff --git a/plugins/mod_presence.lua b/plugins/mod_presence.lua index 3f9a0c12..f939fa00 100644 --- a/plugins/mod_presence.lua +++ b/plugins/mod_presence.lua @@ -15,19 +15,19 @@ 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; -local datetime = require "util.datetime"; +local st = require "prosody.util.stanza"; +local jid_split = require "prosody.util.jid".split; +local jid_bare = require "prosody.util.jid".bare; +local datetime = require "prosody.util.datetime"; local hosts = prosody.hosts; local bare_sessions = prosody.bare_sessions; local full_sessions = prosody.full_sessions; local NULL = {}; -local rostermanager = require "core.rostermanager"; -local sessionmanager = require "core.sessionmanager"; +local rostermanager = require "prosody.core.rostermanager"; +local sessionmanager = require "prosody.core.sessionmanager"; -local recalc_resource_map = require "util.presence".recalc_resource_map; +local recalc_resource_map = require "prosody.util.presence".recalc_resource_map; local ignore_presence_priority = module:get_option_boolean("ignore_presence_priority", false); diff --git a/plugins/mod_private.lua b/plugins/mod_private.lua index 6046d490..2359494c 100644 --- a/plugins/mod_private.lua +++ b/plugins/mod_private.lua @@ -7,7 +7,7 @@ -- -local st = require "util.stanza" +local st = require "prosody.util.stanza" local private_storage = module:open_store("private", "map"); diff --git a/plugins/mod_proxy65.lua b/plugins/mod_proxy65.lua index 069ce0a9..38acc79a 100644 --- a/plugins/mod_proxy65.lua +++ b/plugins/mod_proxy65.lua @@ -9,11 +9,11 @@ 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 server = require "net.server"; -local portmanager = require "core.portmanager"; +local jid_compare, jid_prep = require "prosody.util.jid".compare, require "prosody.util.jid".prep; +local st = require "prosody.util.stanza"; +local sha1 = require "prosody.util.hashes".sha1; +local server = require "prosody.net.server"; +local portmanager = require "prosody.core.portmanager"; local sessions = module:shared("sessions"); local transfers = module:shared("transfers"); diff --git a/plugins/mod_pubsub/mod_pubsub.lua b/plugins/mod_pubsub/mod_pubsub.lua index ef31f326..4f83088a 100644 --- a/plugins/mod_pubsub/mod_pubsub.lua +++ b/plugins/mod_pubsub/mod_pubsub.lua @@ -1,10 +1,9 @@ -local pubsub = require "util.pubsub"; -local st = require "util.stanza"; -local jid_bare = require "util.jid".bare; -local usermanager = require "core.usermanager"; -local new_id = require "util.id".medium; -local storagemanager = require "core.storagemanager"; -local xtemplate = require "util.xtemplate"; +local pubsub = require "prosody.util.pubsub"; +local st = require "prosody.util.stanza"; +local jid_bare = require "prosody.util.jid".bare; +local new_id = require "prosody.util.id".medium; +local storagemanager = require "prosody.core.storagemanager"; +local xtemplate = require "prosody.util.xtemplate"; local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event"; @@ -13,7 +12,7 @@ local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner"; local autocreate_on_publish = module:get_option_boolean("autocreate_on_publish", false); local autocreate_on_subscribe = module:get_option_boolean("autocreate_on_subscribe", false); local pubsub_disco_name = module:get_option_string("name", "Prosody PubSub Service"); -local expose_publisher = module:get_option_boolean("expose_publisher", false) +local service_expose_publisher = module:get_option_boolean("expose_publisher") local service; @@ -40,7 +39,7 @@ end -- get(node_name) -- users(): iterator over (node_name) -local max_max_items = module:get_option_number("pubsub_max_items", 256); +local max_max_items = module:get_option_integer("pubsub_max_items", 256, 1); local function tonumber_max_items(n) if n == "max" then @@ -82,7 +81,11 @@ function simple_broadcast(kind, node, jids, item, actor, node_obj, service) --lu if node_obj and node_obj.config.include_payload == false then item:maptags(function () return nil; end); end - if not expose_publisher then + local node_expose_publisher = service_expose_publisher; + if node_expose_publisher == nil and node_obj and node_obj.config.itemreply == "publisher" then + node_expose_publisher = true; + end + if not node_expose_publisher then item.attr.publisher = nil; elseif not item.attr.publisher and actor ~= true then item.attr.publisher = service.config.normalize_jid(actor); @@ -136,12 +139,22 @@ end -- Compose a textual representation of Atom payloads local summary_templates = module:get_option("pubsub_summary_templates", { - ["http://www.w3.org/2005/Atom"] = "{summary|or{{author/name|and{{author/name} posted }}{title}}}"; + ["http://www.w3.org/2005/Atom"] = "{@pubsub:title|and{*{@pubsub:title}*\n\n}}{summary|or{{author/name|and{{author/name} posted }}{title}}}"; }) for pubsub_type, template in pairs(summary_templates) do module:hook("pubsub-summary/"..pubsub_type, function (event) local payload = event.payload; + + local got_config, node_config = service:get_node_config(event.node, true); + if got_config then + payload = st.clone(payload); + payload.attr["xmlns:pubsub"] = xmlns_pubsub; + payload.attr["pubsub:node"] = event.node; + payload.attr["pubsub:title"] = node_config.title; + payload.attr["pubsub:description"] = node_config.description; + end + return xtemplate.render(template, payload, tostring); end, -1); end @@ -176,10 +189,11 @@ module:hook("host-disco-items", function (event) end end); -local admin_aff = module:get_option_string("default_admin_affiliation", "owner"); +local admin_aff = module:get_option_enum("default_admin_affiliation", "owner", "publisher", "member", "outcast", "none"); +module:default_permission("prosody:admin", ":service-admin"); local function get_affiliation(jid) local bare_jid = jid_bare(jid); - if bare_jid == module.host or usermanager.is_admin(bare_jid, module.host) then + if bare_jid == module.host or module:may(":service-admin", bare_jid) then return admin_aff; end end @@ -192,7 +206,7 @@ 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.expose_publisher = service_expose_publisher; service.config.nodestore = node_store; service.config.itemstore = create_simple_itemstore; @@ -219,7 +233,7 @@ function module.load() set_service(pubsub.new({ autocreate_on_publish = autocreate_on_publish; autocreate_on_subscribe = autocreate_on_subscribe; - expose_publisher = expose_publisher; + expose_publisher = service_expose_publisher; node_defaults = { ["persist_items"] = true; @@ -236,3 +250,46 @@ function module.load() normalize_jid = jid_bare; })); end + +local function get_service(service_jid) + return assert(assert(prosody.hosts[service_jid], "Unknown pubsub service").modules.pubsub, "Not a pubsub service").service; +end + +module:add_item("shell-command", { + section = "pubsub"; + section_desc = "Manage publish/subscribe nodes"; + name = "create_node"; + desc = "Create a node with the specified name"; + args = { + { name = "service_jid", type = "string" }; + { name = "node_name", type = "string" }; + }; + host_selector = "service_jid"; + + handler = function (self, service_jid, node_name) --luacheck: ignore 212/self + return get_service(service_jid):create(node_name, true); + end; +}); + +module:add_item("shell-command", { + section = "pubsub"; + section_desc = "Manage publish/subscribe nodes"; + name = "list_nodes"; + desc = "List nodes on a pubsub service"; + args = { + { name = "service_jid", type = "string" }; + }; + host_selector = "service_jid"; + + handler = function (self, service_jid) --luacheck: ignore 212/self + -- luacheck: ignore 431/service + local service = get_service(service_jid); + local nodes = select(2, assert(service:get_nodes(true))); + local count = 0; + for node_name in pairs(nodes) do + count = count + 1; + self.session.print(node_name); + end + return true, ("%d nodes"):format(count); + end; +}); diff --git a/plugins/mod_pubsub/pubsub.lib.lua b/plugins/mod_pubsub/pubsub.lib.lua index 3196569f..8ae0a896 100644 --- a/plugins/mod_pubsub/pubsub.lib.lua +++ b/plugins/mod_pubsub/pubsub.lib.lua @@ -1,13 +1,13 @@ -local t_unpack = table.unpack or unpack; -- luacheck: ignore 113 +local t_unpack = table.unpack; local time_now = os.time; -local jid_prep = require "util.jid".prep; -local set = require "util.set"; -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 jid_prep = require "prosody.util.jid".prep; +local set = require "prosody.util.set"; +local st = require "prosody.util.stanza"; +local it = require "prosody.util.iterators"; +local uuid_generate = require "prosody.util.uuid".generate; +local dataform = require"prosody.util.dataforms".new; +local errors = require "prosody.util.error"; local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; local xmlns_pubsub_errors = "http://jabber.org/protocol/pubsub#errors"; @@ -110,6 +110,12 @@ local node_config_form = dataform { }; }; { + type = "list-multi"; -- TODO some way to inject options + name = "roster_groups_allowed"; + var = "pubsub#roster_groups_allowed"; + label = "Roster groups allowed to subscribe"; + }; + { type = "list-single"; name = "publish_model"; var = "pubsub#publish_model"; @@ -164,6 +170,17 @@ local node_config_form = dataform { var = "pubsub#notify_retract"; value = true; }; + { + type = "list-single"; + label = "Specify whose JID to include as the publisher of items"; + name = "itemreply"; + var = "pubsub#itemreply"; + options = { + { label = "Include the node owner's JID", value = "owner" }; + { label = "Include the item publisher's JID", value = "publisher" }; + { label = "Don't include any JID with items", value = "none", default = true }; + }; + }; }; _M.node_config_form = node_config_form; @@ -347,6 +364,13 @@ function handlers.get_items(origin, stanza, items, service) origin.send(pubsub_error_reply(stanza, "nodeid-required")); return true; end + + local node_obj = service.nodes[node]; + if not node_obj then + origin.send(pubsub_error_reply(stanza, "item-not-found")); + return true; + end + local resultspec; -- TODO rsm.get() if items.attr.max_items then resultspec = { max = tonumber(items.attr.max_items) }; @@ -358,6 +382,9 @@ function handlers.get_items(origin, stanza, items, service) end local expose_publisher = service.config.expose_publisher; + if expose_publisher == nil and node_obj.config.itemreply == "publisher" then + expose_publisher = true; + end local data = st.stanza("items", { node = node }); local iter, v, i = ipairs(results); @@ -678,8 +705,7 @@ end function handlers.set_retract(origin, stanza, retract, service) local node, notify = retract.attr.node, retract.attr.notify; notify = (notify == "1") or (notify == "true"); - local item = retract:get_child("item"); - local id = item and item.attr.id + local id = retract:get_child_attr("item", nil, "id"); if not (node and id) then origin.send(pubsub_error_reply(stanza, node and "item-not-found" or "nodeid-required")); return true; diff --git a/plugins/mod_register_ibr.lua b/plugins/mod_register_ibr.lua index 8042de7e..ee47a1e0 100644 --- a/plugins/mod_register_ibr.lua +++ b/plugins/mod_register_ibr.lua @@ -7,19 +7,21 @@ -- -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_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 st = require "prosody.util.stanza"; +local dataform_new = require "prosody.util.dataforms".new; +local usermanager_user_exists = require "prosody.core.usermanager".user_exists; +local usermanager_create_user_with_role = require "prosody.core.usermanager".create_user_with_role; +local usermanager_set_password = require "prosody.core.usermanager".create_user; +local usermanager_delete_user = require "prosody.core.usermanager".delete_user; +local nodeprep = require "prosody.util.encodings".stringprep.nodeprep; +local util_error = require "prosody.util.error"; + +local additional_fields = module:get_option_array("additional_registration_fields", {}); local require_encryption = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true)); +local default_role = module:get_option_string("register_ibr_default_role", "prosody:registered"); + pcall(function () module:depends("register_limits"); end); @@ -166,7 +168,12 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event) return true; end - local user = { username = username, password = password, host = host, additional = data, ip = session.ip, session = session, allowed = true } + local user = { + username = username, password = password, host = host; + additional = data, ip = session.ip, session = session; + role = default_role; + allowed = true; + }; module:fire_event("user-registering", user); if not user.allowed then local error_type, error_condition, reason; @@ -200,7 +207,7 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event) end end - local created, err = usermanager_create_user(username, password, host); + local created, err = usermanager_create_user_with_role(username, password, host, user.role); if created then data.registered = os.time(); if not account_details:set(username, data) then diff --git a/plugins/mod_register_limits.lua b/plugins/mod_register_limits.lua index cb430f7f..e127bb86 100644 --- a/plugins/mod_register_limits.lua +++ b/plugins/mod_register_limits.lua @@ -7,23 +7,23 @@ -- -local create_throttle = require "util.throttle".create; -local new_cache = require "util.cache".new; -local ip_util = require "util.ip"; +local create_throttle = require "prosody.util.throttle".create; +local new_cache = require "prosody.util.cache".new; +local ip_util = require "prosody.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"; +local errors = require "prosody.util.error"; -- COMPAT drop old option names -local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations"); +local min_seconds_between_registrations = module:get_option_period("min_seconds_between_registrations"); 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 throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1, 0); +local throttle_period = module:get_option_period("registration_throttle_period", min_seconds_between_registrations); +local throttle_cache_size = module:get_option_integer("registration_throttle_cache_size", 100, 1); local blocklist_overflow = module:get_option_boolean("blocklist_on_registration_throttle_overload", module:get_option_boolean("blacklist_on_registration_throttle_overload", false)); diff --git a/plugins/mod_roster.lua b/plugins/mod_roster.lua index 37fa197a..53b404f7 100644 --- a/plugins/mod_roster.lua +++ b/plugins/mod_roster.lua @@ -7,18 +7,18 @@ -- -local st = require "util.stanza" +local st = require "prosody.util.stanza" -local jid_split = require "util.jid".split; -local jid_resource = require "util.jid".resource; -local jid_prep = require "util.jid".prep; +local jid_split = require "prosody.util.jid".split; +local jid_resource = require "prosody.util.jid".resource; +local jid_prep = require "prosody.util.jid".prep; local tonumber = tonumber; local pairs = pairs; -local rm_load_roster = require "core.rostermanager".load_roster; -local rm_remove_from_roster = require "core.rostermanager".remove_from_roster; -local rm_add_to_roster = require "core.rostermanager".add_to_roster; -local rm_roster_push = require "core.rostermanager".roster_push; +local rm_load_roster = require "prosody.core.rostermanager".load_roster; +local rm_remove_from_roster = require "prosody.core.rostermanager".remove_from_roster; +local rm_add_to_roster = require "prosody.core.rostermanager".add_to_roster; +local rm_roster_push = require "prosody.core.rostermanager".roster_push; module:add_feature("jabber:iq:roster"); diff --git a/plugins/mod_s2s.lua b/plugins/mod_s2s.lua index ee65ba70..04fd5bc3 100644 --- a/plugins/mod_s2s.lua +++ b/plugins/mod_s2s.lua @@ -16,32 +16,38 @@ local tostring, type = tostring, type; 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; -local new_xmpp_stream = require "util.xmppstream".new; -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 async = require "util.async"; +local add_task = require "prosody.util.timer".add_task; +local stop_timer = require "prosody.util.timer".stop; +local st = require "prosody.util.stanza"; +local initialize_filters = require "prosody.util.filters".initialize; +local nameprep = require "prosody.util.encodings".stringprep.nameprep; +local new_xmpp_stream = require "prosody.util.xmppstream".new; +local s2s_new_incoming = require "prosody.core.s2smanager".new_incoming; +local s2s_new_outgoing = require "prosody.core.s2smanager".new_outgoing; +local s2s_destroy_session = require "prosody.core.s2smanager".destroy_session; +local uuid_gen = require "prosody.util.uuid".generate; +local async = require "prosody.util.async"; local runner = async.runner; -local connect = require "net.connect".connect; -local service = require "net.resolvers.service"; -local resolver_chain = require "net.resolvers.chain"; -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); +local connect = require "prosody.net.connect".connect; +local service = require "prosody.net.resolvers.service"; +local resolver_chain = require "prosody.net.resolvers.chain"; +local errors = require "prosody.util.error"; +local set = require "prosody.util.set"; + +local connect_timeout = module:get_option_period("s2s_timeout", 90); +local stream_close_timeout = module:get_option_period("s2s_close_timeout", 5); local opt_keepalives = module:get_option_boolean("s2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true)); local secure_auth = module:get_option_boolean("s2s_secure_auth", false); -- One day... local secure_domains, insecure_domains = module:get_option_set("s2s_secure_domains", {})._items, module:get_option_set("s2s_insecure_domains", {})._items; local require_encryption = module:get_option_boolean("s2s_require_encryption", true); -local stanza_size_limit = module:get_option_number("s2s_stanza_size_limit", 1024*512); +local stanza_size_limit = module:get_option_integer("s2s_stanza_size_limit", 1024*512, 10000); + +local advertised_idle_timeout = 14*60; -- default in all net.server implementations +local network_settings = module:get_option("network_settings"); +if type(network_settings) == "table" and type(network_settings.read_timeout) == "number" then + advertised_idle_timeout = network_settings.read_timeout; +end local measure_connections_inbound = module:metric( "gauge", "connections_inbound", "", @@ -95,6 +101,12 @@ local s2s_service_options = { }; local s2s_service_options_mt = { __index = s2s_service_options } +if module:get_option_boolean("use_dane", false) then + -- DANE is supported in net.connect but only for outgoing connections, + -- to authenticate incoming connections with DANE we need + module:depends("s2s_auth_dane_in"); +end + module:hook("stats-update", function () measure_connections_inbound:clear() measure_connections_outbound:clear() @@ -146,17 +158,17 @@ local function bounce_sendq(session, reason) 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 = 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_text):up(); - end + for i, stanza in ipairs(sendq) do + if not stanza.attr.xmlns and bouncy_stanzas[stanza.name] and stanza.attr.type ~= "error" and stanza.attr.type ~= "result" then + local reply = st.error_reply( + stanza, + error_type, + condition, + reason_text and ("Server-to-server connection failed: "..reason_text) or nil + ); core_process_stanza(dummy, reply); + else + (session.log or log)("debug", "Not eligible for bouncing, discarding %s", stanza:top_tag()); end sendq[i] = nil; end @@ -182,15 +194,11 @@ function route_to_existing_session(event) (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); + t_insert(host.sendq, st.clone(stanza)); else -- luacheck: ignore 122 - host.sendq = { queued_item }; + host.sendq = { st.clone(stanza) }; end host.log("debug", "stanza [%s] queued ", stanza.name); return true; @@ -215,7 +223,7 @@ function route_to_new_session(event) -- Store in buffer host_session.bounce_sendq = bounce_sendq; - host_session.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} }; + host_session.sendq = { st.clone(stanza) }; 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 @@ -255,9 +263,37 @@ function module.add_host(module) end module:hook("route/remote", route_to_existing_session, -1); module:hook("route/remote", route_to_new_session, -10); + module:hook("s2sout-stream-features", function (event) + if not (stanza_size_limit or advertised_idle_timeout) then return end + local limits = event.features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" }) + if stanza_size_limit then + limits:text_tag("max-bytes", string.format("%d", stanza_size_limit)); + end + if advertised_idle_timeout then + limits:text_tag("idle-seconds", string.format("%d", advertised_idle_timeout)); + end + limits:up(); + end); + module:hook_tag("urn:xmpp:bidi", "bidi", function(session, stanza) + -- Advertising features on bidi connections where no <stream:features> is sent in the other direction + local limits = stanza:get_child("limits", "urn:xmpp:stream-limits:0"); + if limits then + session.outgoing_stanza_size_limit = tonumber(limits:get_child_text("max-bytes")); + end + end, 100); module:hook("s2s-authenticated", make_authenticated, -1); module:hook("s2s-read-timeout", keepalive, -1); + module:hook("smacks-ack-delayed", function (event) + if event.origin.type == "s2sin" or event.origin.type == "s2sout" then + event.origin:close("connection-timeout"); + return true; + end + end, -1); module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) -- luacheck: ignore 212/stanza + local limits = stanza:get_child("limits", "urn:xmpp:stream-limits:0"); + if limits then + session.outgoing_stanza_size_limit = tonumber(limits:get_child_text("max-bytes")); + end if session.type == "s2sout" then -- Stream is authenticated and we are seem to be done with feature negotiation, -- so the stream is ready for stanzas. RFC 6120 Section 4.3 @@ -283,7 +319,7 @@ function module.add_host(module) 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 + if session.host == module.host then session:close("host-gone"); end end @@ -328,8 +364,8 @@ function mark_connected(session) if sendq then session.log("debug", "sending %d queued stanzas across new outgoing connection to %s", #sendq, session.to_host); local send = session.sends2s; - for i, data in ipairs(sendq) do - send(data[1]); + for i, stanza in ipairs(sendq) do + send(stanza); sendq[i] = nil; end session.sendq = nil; @@ -393,10 +429,10 @@ end --- Helper to check that a session peer's certificate is valid local function check_cert_status(session) local host = session.direction == "outgoing" and session.to_host or session.from_host - local conn = session.conn:socket() + local conn = session.conn local cert - if conn.getpeercertificate then - cert = conn:getpeercertificate() + if conn.ssl_peercertificate then + cert = conn:ssl_peercertificate() end return module:fire_event("s2s-check-certificate", { host = host, session = session, cert = cert }); @@ -408,8 +444,7 @@ local function session_secure(session) session.secure = true; session.encrypted = true; - local sock = session.conn:socket(); - local info = sock.info and sock:info(); + local info = session.conn:ssl_info(); if type(info) == "table" then (session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher); session.compressed = info.compression; @@ -438,7 +473,8 @@ function stream_callbacks._streamopened(session, attr) session.had_stream = true; -- Had a stream opened at least once -- TODO: Rename session.secure to session.encrypted - if session.secure == false then + if session.secure == false then -- Set by mod_tls during STARTTLS handshake + session.starttls = "completed"; session_secure(session); end @@ -526,6 +562,18 @@ function stream_callbacks._streamopened(session, attr) end if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then + if stanza_size_limit or advertised_idle_timeout then + features:reset(); + local limits = features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" }); + if stanza_size_limit then + limits:text_tag("max-bytes", string.format("%d", stanza_size_limit)); + end + if advertised_idle_timeout then + limits:text_tag("idle-seconds", string.format("%d", advertised_idle_timeout)); + end + features:reset(); + end + log("debug", "Sending stream features: %s", features); session.sends2s(features); else @@ -760,6 +808,7 @@ local function initialize_session(session) local w = conn.write; if conn:ssl() then + -- Direct TLS was used session_secure(session); end @@ -770,6 +819,11 @@ local function initialize_session(session) end if t then t = filter("bytes/out", tostring(t)); + if session.outgoing_stanza_size_limit and #t > session.outgoing_stanza_size_limit then + log("warn", "Attempt to send a stanza exceeding session limit of %dB (%dB)!", session.outgoing_stanza_size_limit, #t); + -- TODO Pass identifiable error condition back to allow appropriate handling + return false + end if t then return w(conn, t); end @@ -932,14 +986,27 @@ 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 + if type(session.cert_chain_errors) == "table" 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"; + elseif cert_errors:contains("no matching DANE TLSA records") then + return "does not match any DANE TLSA records"; + end + + local chain_errors = set.new(session.cert_chain_errors[2]); + for i, e in pairs(session.cert_chain_errors) do + if i > 2 then chain_errors:add_list(e); end + end + if chain_errors:contains("certificate has expired") then + return "has an expired certificate chain"; + elseif chain_errors:contains("no matching DANE TLSA records") then + return "does not match any DANE TLSA records"; end end + -- TODO cert_chain_errors can be a string, handle that return "is not trusted"; -- for some other reason elseif session.cert_identity_status == "invalid" then return "is not valid for this name"; @@ -966,6 +1033,8 @@ function check_auth_policy(event) -- 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. + -- + -- Note: Bounce message must not include name of server, as it may leak half your JID in semi-anon MUCs. session:close({ condition = "not-authorized", text = "Your server's certificate "..reason }, nil, "Remote server's certificate "..reason); return false; @@ -976,7 +1045,7 @@ module:hook("s2s-check-certificate", check_auth_policy, -1); module:hook("server-stopping", function(event) -- Close ports - local pm = require "core.portmanager"; + local pm = require "prosody.core.portmanager"; for _, netservice in pairs(module.items["net-provider"]) do pm.unregister_service(netservice.name, netservice); end diff --git a/plugins/mod_s2s_auth_certs.lua b/plugins/mod_s2s_auth_certs.lua index 992ee934..2517c95f 100644 --- a/plugins/mod_s2s_auth_certs.lua +++ b/plugins/mod_s2s_auth_certs.lua @@ -1,7 +1,6 @@ module:set_global(); -local cert_verify_identity = require "util.x509".verify_identity; -local NULL = {}; +local cert_verify_identity = require "prosody.util.x509".verify_identity; local log = module._log; local measure_cert_statuses = module:metric("counter", "checked", "", "Certificate validation results", @@ -9,25 +8,26 @@ local measure_cert_statuses = module:metric("counter", "checked", "", "Certifica module:hook("s2s-check-certificate", function(event) local session, host, cert = event.session, event.host, event.cert; - local conn = session.conn:socket(); + local conn = session.conn; local log = session.log or log; + local secure_hostname = conn.extra and conn.extra.secure_hostname; + if not cert then log("warn", "No certificate provided by %s", host or "unknown host"); return; end - local chain_valid, errors; - if conn.getpeerverification then - chain_valid, errors = conn:getpeerverification(); - else - chain_valid, errors = false, { { "Chain verification not supported by this version of LuaSec" } }; - end + local chain_valid, errors = conn:ssl_peerverification(); -- Is there any interest in printing out all/the number of errors here? if not chain_valid then log("debug", "certificate chain validation result: invalid"); - for depth, t in pairs(errors or NULL) do - log("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", ")) + if type(errors) == "table" then + for depth, t in pairs(errors) do + log("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", ")); + end + else + log("debug", "certificate error: %s", errors); end session.cert_chain_status = "invalid"; session.cert_chain_errors = errors; @@ -45,6 +45,14 @@ module:hook("s2s-check-certificate", function(event) end log("debug", "certificate identity validation result: %s", session.cert_identity_status); end + + -- Check for DNSSEC-signed SRV hostname + if secure_hostname and session.cert_identity_status ~= "valid" then + if cert_verify_identity(secure_hostname, "xmpp-server", cert) then + module:log("info", "Secure SRV name delegation %q -> %q", secure_hostname, host); + session.cert_identity_status = "valid" + end + 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_auth_dane_in.lua b/plugins/mod_s2s_auth_dane_in.lua new file mode 100644 index 00000000..9167e8a9 --- /dev/null +++ b/plugins/mod_s2s_auth_dane_in.lua @@ -0,0 +1,130 @@ +module:set_global(); + +local dns = require "prosody.net.adns"; +local async = require "prosody.util.async"; +local encodings = require "prosody.util.encodings"; +local hashes = require "prosody.util.hashes"; +local promise = require "prosody.util.promise"; +local x509 = require "prosody.util.x509"; + +local idna_to_ascii = encodings.idna.to_ascii; +local sha256 = hashes.sha256; +local sha512 = hashes.sha512; + +local use_dane = module:get_option_boolean("use_dane", nil); +if use_dane == nil then + module:log("warn", "DANE support incomplete, add use_dane = true in the global section to support outgoing s2s connections"); +elseif use_dane == false then + module:log("debug", "DANE support disabled with use_dane = false, disabling.") + return +end + +local function ensure_secure(r) + assert(r.secure, "insecure"); + return r; +end + +local function ensure_nonempty(r) + assert(r[1], "empty"); + return r; +end + +local function flatten(a) + local seen = {}; + local ret = {}; + for _, rrset in ipairs(a) do + for _, rr in ipairs(rrset) do + if not seen[tostring(rr)] then + table.insert(ret, rr); + seen[tostring(rr)] = true; + end + end + end + return ret; +end + +local lazy_tlsa_mt = { + __index = function(t, i) + if i == 1 then + local h = sha256(t[0]); + t[1] = h; + return h; + elseif i == 2 then + local h = sha512(t[0]); + t[1] = h; + return h; + end + end; +} +local function lazy_hash(t) + return setmetatable(t, lazy_tlsa_mt); +end + +module:hook("s2s-check-certificate", function(event) + local session, host, cert = event.session, event.host, event.cert; + local log = session.log or module._log; + + if not host or not cert or session.direction ~= "incoming" then + return + end + + local by_select_match = { + [0] = lazy_hash { + -- cert + [0] = x509.pem2der(cert:pem()); + + }; + } + if cert.pubkey then + by_select_match[1] = lazy_hash { + -- spki + [0] = x509.pem2der(cert:pubkey()); + }; + end + + local resolver = dns.resolver(); + + local dns_domain = idna_to_ascii(host); + + local function fetch_tlsa(res) + local tlsas = {}; + for _, rr in ipairs(res) do + if rr.srv.target == "." then return {}; end + table.insert(tlsas, resolver:lookup_promise(("_%d._tcp.%s"):format(rr.srv.port, rr.srv.target), "TLSA"):next(ensure_secure)); + end + return promise.all(tlsas):next(flatten); + end + + local ret = async.wait_for(resolver:lookup_promise("_xmpp-server." .. dns_domain, "TLSA"):next(ensure_secure):next(ensure_nonempty):catch(function() + return promise.all({ + resolver:lookup_promise("_xmpps-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa); + resolver:lookup_promise("_xmpp-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa); + }):next(flatten); + end)); + + if not ret then + return + end + + local found_supported = false; + for _, rr in ipairs(ret) do + if rr.tlsa.use == 3 and by_select_match[rr.tlsa.select] and rr.tlsa.match <= 2 then + found_supported = true; + if rr.tlsa.data == by_select_match[rr.tlsa.select][rr.tlsa.match] then + module:log("debug", "%s matches", rr) + session.cert_chain_status = "valid"; + session.cert_identity_status = "valid"; + return true; + end + else + log("debug", "Unsupported DANE TLSA record: %s", rr); + end + end + + if found_supported then + session.cert_chain_status = "invalid"; + session.cert_identity_status = nil; + return true; + end + +end, 800); diff --git a/plugins/mod_s2s_bidi.lua b/plugins/mod_s2s_bidi.lua index addcd6e2..8588ce59 100644 --- a/plugins/mod_s2s_bidi.lua +++ b/plugins/mod_s2s_bidi.lua @@ -5,17 +5,22 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.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", true); +local offers_sent = module:metric("counter", "offers_sent", "", "Bidirectional connection offers sent", {}); +local offers_recv = module:metric("counter", "offers_recv", "", "Bidirectional connection offers received", {}); +local offers_taken = module:metric("counter", "offers_taken", "", "Bidirectional connection offers taken", {}); + 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(); + offers_sent:with_labels():add(1); end end); @@ -25,7 +30,10 @@ module:hook_tag("http://etherx.jabber.org/streams", "features", function (sessio if bidi then session.incoming = true; session.log("debug", "Requesting bidirectional stream"); - session.sends2s(st.stanza("bidi", { xmlns = xmlns_bidi })); + local request_bidi = st.stanza("bidi", { xmlns = xmlns_bidi }); + module:fire_event("s2sout-stream-features", { origin = session, features = request_bidi }); + session.sends2s(request_bidi); + offers_taken:with_labels():add(1); end end end, 200); @@ -34,6 +42,7 @@ 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; + offers_recv:with_labels():add(1); return true; end end); diff --git a/plugins/mod_saslauth.lua b/plugins/mod_saslauth.lua index ab863aa3..b6cd31c8 100644 --- a/plugins/mod_saslauth.lua +++ b/plugins/mod_saslauth.lua @@ -8,19 +8,26 @@ -- luacheck: ignore 431/log -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 st = require "prosody.util.stanza"; +local sm_bind_resource = require "prosody.core.sessionmanager".bind_resource; +local sm_make_authenticated = require "prosody.core.sessionmanager".make_authenticated; +local base64 = require "prosody.util.encodings".base64; +local set = require "prosody.util.set"; +local errors = require "prosody.util.error"; +local hex = require "prosody.util.hex"; +local pem2der = require"util.x509".pem2der; +local hashes = require"util.hashes"; +local ssl = require "ssl"; -- FIXME Isolate LuaSec from the rest of the code + +local certmanager = require "core.certmanager"; +local pm_get_tls_config_at = require "prosody.core.portmanager".get_tls_config_at; +local usermanager_get_sasl_handler = require "prosody.core.usermanager".get_sasl_handler; local secure_auth_only = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true)); local allow_unencrypted_plain_auth = module:get_option_boolean("allow_unencrypted_plain_auth", false) local insecure_mechanisms = module:get_option_set("insecure_sasl_mechanisms", allow_unencrypted_plain_auth and {} or {"PLAIN", "LOGIN"}); local disabled_mechanisms = module:get_option_set("disable_sasl_mechanisms", { "DIGEST-MD5" }); +local tls_server_end_point_hash = module:get_option_string("tls_server_end_point_hash"); local log = module._log; @@ -49,11 +56,14 @@ local function handle_status(session, status, ret, err_msg) return "failure", "temporary-auth-failure", "Connection gone"; end if status == "failure" then - module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg }); + local event = { session = session, condition = ret, text = err_msg }; + module:fire_event("authentication-failure", event); session.sasl_handler = session.sasl_handler:clean_clone(); + ret, err_msg = event.condition, event.text; elseif status == "success" then - local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.scope); + local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.role); if ok then + session.sasl_resource = session.sasl_handler.resource; module:fire_event("authentication-success", { session = session }); session.sasl_handler = nil; session:reset_stream(); @@ -77,9 +87,12 @@ local function sasl_process_cdata(session, stanza) return true; end end - local status, ret, err_msg = session.sasl_handler:process(text); + local sasl_handler = session.sasl_handler; + local status, ret, err_msg = sasl_handler:process(text); status, ret, err_msg = handle_status(session, status, ret, err_msg); - local s = build_reply(status, ret, err_msg); + local event = { session = session, message = ret, error_text = err_msg }; + module:fire_event("sasl/"..session.base_type.."/"..status, event); + local s = build_reply(status, event.message, event.error_text); session.send(s); return true; end @@ -205,6 +218,12 @@ module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:auth", function(event) if session.type ~= "c2s_unauthed" or module:get_host_type() ~= "local" then return; end + -- event for preemptive checks, rate limiting etc + module:fire_event("authentication-attempt", event); + if event.allowed == false then + session.send(build_reply("failure", event.error_condition or "not-authorized", event.error_text)); + return true; + end if session.sasl_handler and session.sasl_handler.selected then session.sasl_handler = nil; -- allow starting a new SASL negotiation before completing an old one end @@ -242,7 +261,53 @@ module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:abort", function(event) end); local function tls_unique(self) - return self.userdata["tls-unique"]:getpeerfinished(); + return self.userdata["tls-unique"]:ssl_peerfinished(); +end + +local function tls_exporter(conn) + if not conn.ssl_exportkeyingmaterial then return end + return conn:ssl_exportkeyingmaterial("EXPORTER-Channel-Binding", 32, ""); +end + +local function sasl_tls_exporter(self) + return tls_exporter(self.userdata["tls-exporter"]); +end + +local function tls_server_end_point(self) + local cert_hash = self.userdata["tls-server-end-point"]; + if cert_hash then return hex.from(cert_hash); end + + local conn = self.userdata["tls-server-end-point-conn"]; + local cert = conn.getlocalcertificate and conn:getlocalcertificate(); + + if not cert then + -- We don't know that this is the right cert, it could have been replaced on + -- disk since we started. + local certfile = self.userdata["tls-server-end-point-cert"]; + if not certfile then return end + local f = io.open(certfile); + if not f then return end + local certdata = f:read("*a"); + f:close(); + cert = ssl.loadcertificate(certdata); + end + + -- Hash function selection, see RFC 5929 §4.1 + local hash, hash_name = hashes.sha256, "sha256"; + if cert.getsignaturename then + local sigalg = cert:getsignaturename():lower():match("sha%d+"); + if sigalg and sigalg ~= "sha1" and hashes[sigalg] then + -- This should have ruled out MD5 and SHA1 + hash, hash_name = hashes[sigalg], sigalg; + end + end + + local certdata_der = pem2der(cert:pem()); + local hashed_der = hash(certdata_der); + + module:log("debug", "tls-server-end-point: hex(%s(der)) = %q, hash = %s", hash_name, hex.encode(hashed_der)); + + return hashed_der; end local mechanisms_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-sasl' }; @@ -258,22 +323,60 @@ module:hook("stream-features", function(event) end local sasl_handler = usermanager_get_sasl_handler(module.host, origin) origin.sasl_handler = sasl_handler; + local channel_bindings = set.new() if origin.encrypted then -- 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(); - local info = socket.info and socket:info(); - if info.protocol == "TLSv1.3" then + local info = origin.conn:ssl_info(); + if info and 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 + if tls_exporter(origin.conn) then + log("debug", "Channel binding 'tls-exporter' supported"); + sasl_handler:add_cb_handler("tls-exporter", sasl_tls_exporter); + channel_bindings:add("tls-exporter"); + else + log("debug", "Channel binding 'tls-exporter' not supported"); + end + elseif origin.conn.ssl_peerfinished and origin.conn:ssl_peerfinished() then log("debug", "Channel binding 'tls-unique' supported"); sasl_handler:add_cb_handler("tls-unique", tls_unique); + channel_bindings:add("tls-unique"); else log("debug", "Channel binding 'tls-unique' not supported (by LuaSec?)"); end + + local certfile; + if tls_server_end_point_hash == "auto" then + tls_server_end_point_hash = nil; + local ssl_cfg = origin.ssl_cfg; + if not ssl_cfg then + local server = origin.conn:server(); + local tls_config = pm_get_tls_config_at(server:ip(), server:serverport()); + local autocert = certmanager.find_host_cert(origin.conn:socket():getsniname()); + ssl_cfg = autocert or tls_config; + end + + certfile = ssl_cfg and ssl_cfg.certificate; + if certfile then + log("debug", "Channel binding 'tls-server-end-point' can be offered based on the certificate used"); + sasl_handler:add_cb_handler("tls-server-end-point", tls_server_end_point); + channel_bindings:add("tls-server-end-point"); + else + log("debug", "Channel binding 'tls-server-end-point' set to 'auto' but cannot determine cert"); + end + elseif tls_server_end_point_hash then + log("debug", "Channel binding 'tls-server-end-point' can be offered with the configured certificate hash"); + sasl_handler:add_cb_handler("tls-server-end-point", tls_server_end_point); + channel_bindings:add("tls-server-end-point"); + end + sasl_handler["userdata"] = { - ["tls-unique"] = socket; + ["tls-unique"] = origin.conn; + ["tls-exporter"] = origin.conn; + ["tls-server-end-point-cert"] = certfile; + ["tls-server-end-point-conn"] = origin.conn; + ["tls-server-end-point"] = tls_server_end_point_hash; }; else log("debug", "Channel binding not supported by SASL handler"); @@ -306,6 +409,14 @@ module:hook("stream-features", function(event) mechanisms:tag("mechanism"):text(mechanism):up(); end features:add_child(mechanisms); + if not channel_bindings:empty() then + -- XXX XEP-0440 is Experimental + features:tag("sasl-channel-binding", {xmlns='urn:xmpp:sasl-cb:0'}) + for channel_binding in channel_bindings do + features:tag("channel-binding", {type=channel_binding}):up() + end + features:up(); + end return; end @@ -328,7 +439,7 @@ module:hook("stream-features", function(event) authmod, available_disabled); end - else + elseif not origin.full_jid then features:tag("bind", bind_attr):tag("required"):up():up(); features:tag("session", xmpp_session_attr):tag("optional"):up():up(); end @@ -350,14 +461,15 @@ end); module:hook("stanza/iq/urn:ietf:params:xml:ns:xmpp-bind:bind", function(event) local origin, stanza = event.origin, event.stanza; - local resource; - if stanza.attr.type == "set" then + local resource = origin.sasl_resource; + if stanza.attr.type == "set" and not resource then local bind = stanza.tags[1]; resource = bind:get_child("resource"); resource = resource and #resource.tags == 0 and resource[1] or nil; end local success, err_type, err, err_msg = sm_bind_resource(origin, resource); if success then + origin.sasl_resource = nil; origin.send(st.reply(stanza) :tag("bind", { xmlns = xmlns_bind }) :tag("jid"):text(origin.full_jid)); diff --git a/plugins/mod_scansion_record.lua b/plugins/mod_scansion_record.lua index 5fefd398..1ec55952 100644 --- a/plugins/mod_scansion_record.lua +++ b/plugins/mod_scansion_record.lua @@ -2,11 +2,11 @@ local names = { "Romeo", "Juliet", "Mercutio", "Tybalt", "Benvolio" }; local devices = { "", "phone", "laptop", "tablet", "toaster", "fridge", "shoe" }; local users = {}; -local filters = require "util.filters"; -local id = require "util.id"; -local dt = require "util.datetime"; -local dm = require "util.datamanager"; -local st = require "util.stanza"; +local filters = require "prosody.util.filters"; +local id = require "prosody.util.id"; +local dt = require "prosody.util.datetime"; +local dm = require "prosody.util.datamanager"; +local st = require "prosody.util.stanza"; local record_id = id.short():lower(); local record_date = os.date("%Y%b%d"):lower(); diff --git a/plugins/mod_server_contact_info.lua b/plugins/mod_server_contact_info.lua index 42316078..67fed752 100644 --- a/plugins/mod_server_contact_info.lua +++ b/plugins/mod_server_contact_info.lua @@ -6,21 +6,23 @@ -- COPYING file in the source package for more information. -- -local array = require "util.array"; -local jid = require "util.jid"; +local array = require "prosody.util.array"; +local it = require "prosody.util.iterators"; +local jid = require "prosody.util.jid"; local url = require "socket.url"; +module:depends("server_info"); + -- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo -local form_layout = require "util.dataforms".new({ - { var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo"; }; - { name = "abuse", var = "abuse-addresses", type = "list-multi" }, - { name = "admin", var = "admin-addresses", type = "list-multi" }, - { 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" }, -}); +local address_types = { + abuse = "abuse-addresses"; + admin = "admin-addresses"; + feedback = "feedback-addresses"; + sales = "sales-addresses"; + security = "security-addresses"; + status = "status-addresses"; + support = "support-addresses"; +}; -- JIDs of configured service admins are used as fallback local admins = module:get_option_inherited_set("admins", {}); @@ -29,4 +31,17 @@ local contact_config = module:get_option("contact_info", { 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")); +local fields = {}; + +for key, field_var in it.sorted_pairs(address_types) do + if contact_config[key] then + table.insert(fields, { + type = "list-multi"; + name = key; + var = field_var; + value = contact_config[key]; + }); + end +end + +module:add_item("server-info-fields", fields); diff --git a/plugins/mod_server_info.lua b/plugins/mod_server_info.lua new file mode 100644 index 00000000..5469bf02 --- /dev/null +++ b/plugins/mod_server_info.lua @@ -0,0 +1,55 @@ +local dataforms = require "prosody.util.dataforms"; + +local server_info_config = module:get_option("server_info", {}); +local server_info_custom_fields = module:get_option_array("server_info_extensions"); + +-- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo +local form_layout = dataforms.new({ + { var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo" }; +}); + +if server_info_custom_fields then + for _, field in ipairs(server_info_custom_fields) do + table.insert(form_layout, field); + end +end + +local generated_form; + +function update_form() + local new_form = form_layout:form(server_info_config, "result"); + if generated_form then + module:remove_item("extension", generated_form); + end + generated_form = new_form; + module:add_item("extension", generated_form); +end + +function add_fields(event) + local fields = event.item; + for _, field in ipairs(fields) do + table.insert(form_layout, field); + end + update_form(); +end + +function remove_fields(event) + local removed_fields = event.item; + for _, removed_field in ipairs(removed_fields) do + local removed_var = removed_field.var or removed_field.name; + for i, field in ipairs(form_layout) do + local var = field.var or field.name + if var == removed_var then + table.remove(form_layout, i); + break; + end + end + end + update_form(); +end + +module:handle_items("server-info-fields", add_fields, remove_fields); + +function module.load() + update_form(); +end diff --git a/plugins/mod_smacks.lua b/plugins/mod_smacks.lua index e0a7bbfb..d4f0f371 100644 --- a/plugins/mod_smacks.lua +++ b/plugins/mod_smacks.lua @@ -2,7 +2,7 @@ -- -- Copyright (C) 2010-2015 Matthew Wild -- Copyright (C) 2010 Waqas Hussain --- Copyright (C) 2012-2021 Kim Alvefur +-- Copyright (C) 2012-2022 Kim Alvefur -- Copyright (C) 2012 Thijs Alkemade -- Copyright (C) 2014 Florian Zeitz -- Copyright (C) 2016-2020 Thilo Molitor @@ -10,6 +10,7 @@ -- This project is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. -- +-- TODO unify sendq and smqueue local tonumber = tonumber; local tostring = tostring; @@ -38,23 +39,23 @@ 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, 30, 60, 120, 300, 600 }} + {buckets = {0, 1, 12, 60, 360, 900, 1440, 3600, 14400, 86400}} ):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 datetime = require "prosody.util.datetime"; +local add_filter = require "prosody.util.filters".add_filter; +local jid = require "prosody.util.jid"; +local smqueue = require "prosody.util.smqueue"; +local st = require "prosody.util.stanza"; +local timer = require "prosody.util.timer"; +local new_id = require "prosody.util.id".short; +local watchdog = require "prosody.util.watchdog"; +local it = require"prosody.util.iterators"; -local sessionmanager = require "core.sessionmanager"; +local sessionmanager = require "prosody.core.sessionmanager"; local xmlns_errors = "urn:ietf:params:xml:ns:xmpp-stanzas"; local xmlns_delay = "urn:xmpp:delay"; @@ -65,14 +66,14 @@ 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 queue_size = module:get_option_integer("smacks_max_queue_size", 500, 1); +local resume_timeout = module:get_option_period("smacks_hibernation_time", "10 minutes"); 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 max_unacked_stanzas = module:get_option_integer("smacks_max_unacked_stanzas", 0, 0); +local max_inactive_unacked_stanzas = module:get_option_integer("smacks_max_inactive_unacked_stanzas", 256, 0); +local delayed_ack_timeout = module:get_option_period("smacks_max_ack_delay", 30); +local max_old_sessions = module:get_option_integer("smacks_max_old_sessions", 10, 0); local c2s_sessions = module:shared("/*/c2s/sessions"); local local_sessions = prosody.hosts[module.host].sessions; @@ -83,13 +84,43 @@ 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, { +local function registry_key(session, id) + return jid.join(session.username, session.host, id or session.resumption_token); +end + +local function track_session(session, id) + session_registry[registry_key(session, id)] = session; + session.resumption_token = id; +end + +local function save_old_session(session) + session_registry[registry_key(session)] = nil; + return old_session_registry:set(session.username, session.resumption_token, + { h = session.handled_stanza_count; t = os.time() }) +end + +local function clear_old_session(session, id) + session_registry[registry_key(session, id)] = nil; + return old_session_registry:set(session.username, id or session.resumption_token, nil) +end + +local ack_errors = require"prosody.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" } }); +local enable_errors = require "prosody.util.error".init("mod_smacks", xmlns_sm3, { + already_enabled = { condition = "unexpected-request", text = "Stream management is already enabled" }; + bind_required = { condition = "unexpected-request", text = "Client must bind a resource before enabling stream management" }; + unavailable = { condition = "service-unavailable", text = "Stream management is not available for this stream" }; + -- Resumption + expired = { condition = "item-not-found", text = "Session expired, and cannot be resumed" }; + already_bound = { condition = "unexpected-request", text = "Cannot resume another session after a resource is bound" }; + unknown_session = { condition = "item-not-found", text = "Unknown session" }; +}); + -- COMPAT note the use of compatibility wrapper in events (queue:table()) local function ack_delayed(session, stanza) @@ -104,18 +135,18 @@ local function ack_delayed(session, stanza) end local function can_do_smacks(session, advertise_only) - if session.smacks then return false, "unexpected-request", "Stream management is already enabled"; end + if session.smacks then return false, enable_errors.new("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"; + return false, enable_errors.new("bind_required"); 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"; + return false, enable_errors.new("unavailable"); end module:hook("stream-features", @@ -155,13 +186,12 @@ 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.log("debug", "Sending <r> from %s - #queue=%d", reason, queue:count_unacked()); session.awaiting_ack = true; (session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks })) if session.destroyed then return end -- sending something can trigger destruction -- expected_h could be lower than this expression e.g. more stanzas added to the queue meanwhile) session.last_requested_h = queue:count_acked() + 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 @@ -180,7 +210,6 @@ local function outgoing_stanza_filter(stanza, session) -- 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') @@ -234,8 +263,7 @@ module:hook("pre-session-close", function(event) 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); + clear_old_session(session); session.resumption_token = nil; else session.log("debug", "Session not resumable"); @@ -274,17 +302,16 @@ local function wrap_session(session, resume) return session; end -function handle_enable(session, stanza, xmlns_sm) - local ok, err, err_text = can_do_smacks(session); +function do_enable(session, stanza) + local ok, err = 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; + session.log("warn", "Failed to enable smacks: %s", err.text); -- TODO: XEP doesn't say we can send error text, should it? + return nil, err; end if session.username then local old_sessions, err = all_old_sessions:get(session.username); - module:log("debug", "Old sessions: %q", old_sessions) + session.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) @@ -296,54 +323,73 @@ function handle_enable(session, stanza, xmlns_sm) end all_old_sessions:set(session.username, keep); elseif err then - module:log("error", "Unable to retrieve old resumption counters: %s", err); + session.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") and session.username then -- resumption on s2s is not currently supported 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 { + type = "enabled"; + id = resume_token; + resume_max = resume_token and tostring(resume_timeout) or nil; + session = session; + finish = function () + session.log("debug", "Enabling stream management"); + + session.smacks = stanza.attr.xmlns; + if resume_token then + track_session(session, resume_token); + end + wrap_session(session, false); + end; + }; +end + +function handle_enable(session, stanza, xmlns_sm) + local enabled, err = do_enable(session, stanza); + if not enabled then + (session.sends2s or session.send)(st.stanza("failed", { xmlns = xmlns_sm }):add_error(err)); + return true; + end + + (session.sends2s or session.send)(st.stanza("enabled", { + xmlns = xmlns_sm; + id = enabled.id; + resume = enabled.id and "true" or nil; -- COMPAT w/ Conversations 2.10.10 requires 'true' not '1' + max = enabled.resume_max; + })); + + session.smacks = xmlns_sm; + enabled.finish(); + 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); +module:hook_tag("http://etherx.jabber.org/streams", "features", function(session, stanza) + if can_do_smacks(session) then + session.smacks_feature = stanza:get_child("sm", xmlns_sm3) or stanza:get_child("sm", xmlns_sm2); + end +end); + +module:hook("s2sout-established", function (event) + local session = event.session; + if not session.smacks_feature then return end + + session.smacks = session.smacks_feature.attr.xmlns; + wrap_session_out(session, false); + session.sends2s(st.stanza("enable", { xmlns = session.smacks })); +end); function handle_enabled(session, stanza, xmlns_sm) -- luacheck: ignore 212/stanza - module:log("debug", "Enabling stream management"); + session.log("debug", "Enabling stream management"); session.smacks = xmlns_sm; wrap_session_in(session, false); @@ -357,10 +403,10 @@ module:hook_tag(xmlns_sm3, "enabled", function (session, stanza) return handle_e 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"); + origin.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); + origin.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) @@ -413,13 +459,14 @@ local function handle_unacked_stanzas(session) local queue = session.outgoing_stanza_queue; local unacked = queue:count_unacked() if unacked > 0 then + local error_from = jid.join(session.username, session.host or module.host); 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.from ~= session.full_jid then - local reply = st.error_reply(stanza, "cancel", "recipient-unavailable"); + local reply = st.error_reply(stanza, "cancel", "recipient-unavailable", nil, error_from); module:send(reply); end end @@ -495,11 +542,8 @@ module:hook("pre-resource-unbind", function (event) 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() }); + save_old_session(session); 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); @@ -533,131 +577,110 @@ 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) +function do_resume(session, stanza) 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; + return nil, enable_errors.new("already_bound"); end local id = stanza.attr.previd; - local original_session = session_registry[jid.join(session.username, session.host, id)]; + local original_session = session_registry[registry_key(session, 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); + clear_old_session(session, id); 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(); + return nil, enable_errors.new("expired", { h = old_session.h }); end + session.log("debug", "Tried to resume non-existent session with id %s", id); + return nil, enable_errors.new("unknown_session"); + 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 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 - 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 + session.log("debug", "mod_smacks resuming existing session %s...", original_session.id); - original_session.send(st.stanza("resumed", { xmlns = xmlns_sm, - h = format_h(original_session.handled_stanza_count), previd = id })); + local queue = original_session.outgoing_stanza_queue; + local h = tonumber(stanza.attr.h); - -- Ok, we need to re-send any stanzas that the client didn't see - -- ...they are what is now left in the outgoing stanza queue - -- We have to use the send of "session" because we don't want to add our resent stanzas - -- to the outgoing queue again + original_session.log("debug", "Pre-resumption #queue = %d", queue:count_unacked()) + local acked, err = ack_errors.coerce(queue:ack(h)); -- luacheck: ignore 211/acked - 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); + if not err and not queue:resumable() then + err = ack_errors.new("overflow"); end + + if err then + session.log("debug", "Resumption failed: %s", err); + return nil, err; + end + + -- Update original_session with the parameters (connection, etc.) from the new session + sessionmanager.update_session(original_session, session); + + return { + type = "resumed"; + session = original_session; + id = id; + -- Return function to complete the resumption and resync unacked stanzas + -- This is two steps so we can support SASL2/ISR + finish = function () + -- 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 + + original_session.log("debug", "resending all unacked stanzas that are still queued after resume, #queue = %d", queue:count_unacked()); + for _, queued_stanza in queue:resume() do + original_session.send(queued_stanza); + end + original_session.log("debug", "all stanzas resent, enabling stream management on resumed stream, #queue = %d", queue:count_unacked()); + + -- Add our own handlers to the resumed session (filters have been reset in the update) + wrap_session(original_session, true); + + -- Let everyone know that we are no longer hibernating + 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; + }; +end + +function handle_resume(session, stanza, xmlns_sm) + local resumed, err = do_resume(session, stanza); + if not resumed then + session.send(st.stanza("failed", { xmlns = xmlns_sm, h = format_h(err.context.h) }) + :tag(err.condition, { xmlns = xmlns_errors })); + return true; + end + + session = resumed.session; + + -- Inform client of successful resumption + session.send(st.stanza("resumed", { xmlns = xmlns_sm, + h = format_h(session.handled_stanza_count), previd = resumed.id })); + + -- Complete resume (sync stanzas, etc.) + resumed.finish(); + 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); @@ -712,8 +735,7 @@ module:hook_global("server-stopping", function(event) 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 + if save_old_session(session) then session.resumption_token = nil; -- Deal with unacked stanzas diff --git a/plugins/mod_stanza_debug.lua b/plugins/mod_stanza_debug.lua index af98670c..4feab7ae 100644 --- a/plugins/mod_stanza_debug.lua +++ b/plugins/mod_stanza_debug.lua @@ -1,6 +1,6 @@ module:set_global(); -local filters = require "util.filters"; +local filters = require "prosody.util.filters"; local function log_send(t, session) if t and t ~= "" and t ~= " " then diff --git a/plugins/mod_storage_internal.lua b/plugins/mod_storage_internal.lua index fa87e495..a43dd272 100644 --- a/plugins/mod_storage_internal.lua +++ b/plugins/mod_storage_internal.lua @@ -1,17 +1,20 @@ -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 cache = require "prosody.util.cache"; +local datamanager = require "prosody.core.storagemanager".olddm; +local array = require "prosody.util.array"; +local datetime = require "prosody.util.datetime"; +local st = require "prosody.util.stanza"; +local now = require "prosody.util.time".now; +local id = require "prosody.util.id".medium; +local jid_join = require "prosody.util.jid".join; +local set = require "prosody.util.set"; +local it = require "prosody.util.iterators"; 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 archive_item_limit = module:get_option_integer("storage_archive_item_limit", 10000, 0); +local archive_item_count_cache = cache.new(module:get_option_integer("storage_archive_item_limit_cache_size", 1000, 1)); + +local use_shift = module:get_option_boolean("storage_archive_experimental_fast_delete", false); local driver = {}; @@ -121,100 +124,144 @@ function archive:append(username, key, value, when, with) return key; end +local function binary_search(haystack, test, min, max) + if min == nil then + min = 1; + end + if max == nil then + max = #haystack; + end + + local floor = math.floor; + while min < max do + local mid = floor((max + min) / 2); + + local result = test(haystack[mid]); + if result < 0 then + max = mid; + elseif result > 0 then + min = mid + 1; + else + return mid, haystack[mid]; + end + end + + return min, nil; +end + function archive:find(username, query) - local items, err = datamanager.list_load(username, host, self.store); - if not items then + local list, err = datamanager.list_open(username, host, self.store); + if not list then if err then - return items, err; + return list, err; elseif query then if query.before or query.after then return nil, "item-not-found"; end if query.total then - return function () end, 0; + return function() + end, 0; end end - return function () end; + return function() + end; + end + + local i = 0; + local iter = function() + i = i + 1; + return list[i] end - local count = nil; - local i, last_key = 0; + if query then - items = array(items); + if query.reverse then + i = #list + 1 + iter = function() + i = i - 1 + return list[i] + end + query.before, query.after = query.after, query.before; + end if query.key then - items:filter(function (item) + iter = it.filter(function(item) return item.key == query.key; - end); + end, iter); end if query.ids then local ids = set.new(query.ids); - items:filter(function (item) + iter = it.filter(function(item) return ids:contains(item.key); - end); + end, iter); end if query.with then - items:filter(function (item) + iter = it.filter(function(item) return item.with == query.with; - end); + end, iter); end if query.start then - items:filter(function (item) - local when = item.when or datetime.parse(item.attr.stamp); - return when >= query.start; - end); + if not query.reverse then + local wi = binary_search(list, function(item) + local when = item.when or datetime.parse(item.attr.stamp); + return query.start - when; + end); + i = wi - 1; + else + iter = it.filter(function(item) + local when = item.when or datetime.parse(item.attr.stamp); + return when >= query.start; + end, iter); + end end if query["end"] then - items:filter(function (item) - local when = item.when or datetime.parse(item.attr.stamp); - return when <= query["end"]; - end); - end - if query.total then - count = #items; - end - if query.reverse then - items:reverse(); - if query.before then - 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"; + if query.reverse then + local wi = binary_search(list, function(item) + local when = item.when or datetime.parse(item.attr.stamp); + return query["end"] - when; + end); + if wi then + i = wi + 1; end + else + iter = it.filter(function(item) + local when = item.when or datetime.parse(item.attr.stamp); + return when <= query["end"]; + end, iter); end - last_key = query.after; - elseif query.after then + end + if query.after then local found = false; - for j = 1, #items do - if (items[j].key or tostring(j)) == query.after then - found = true; - i = j; - break; + iter = it.filter(function(item) + local found_after = found; + if item.key == query.after then + found = true end - end - if not found then - return nil, "item-not-found"; - end - last_key = query.before; - elseif query.before then - last_key = query.before; + return found_after; + end, iter); end - if query.limit and #items - i > query.limit then - items[i+query.limit+1] = nil; + if query.before then + local found = false; + iter = it.filter(function(item) + if item.key == query.before then + found = true + end + return not found; + end, iter); + end + if query.limit then + iter = it.head(query.limit, iter); end end - return function () - i = i + 1; - local item = items[i]; - if not item or (last_key and item.key == last_key) then - return; + + return function() + local item = iter(); + if item == nil then + if list.close then + list:close(); + end + return end - local key = item.key or tostring(i); - local when = item.when or datetime.parse(item.attr.stamp); + local key = item.key; + local when = item.when or item.attr and datetime.parse(item.attr.stamp); local with = item.with; item.key, item.when, item.with = nil, nil, nil; item.attr.stamp = nil; @@ -222,7 +269,7 @@ function archive:find(username, query) item.attr.stamp_legacy = nil; item = st.deserialize(item); return key, item, when, with; - end, count; + end end function archive:get(username, wanted_key) @@ -297,12 +344,53 @@ function archive:users() return datamanager.users(host, self.store, "list"); end +function archive:trim(username, to_when) + local cache_key = jid_join(username, host, self.store); + local list, err = datamanager.list_open(username, host, self.store); + if not list then + if err == nil then + module:log("debug", "store already empty, can't trim"); + return 0; + end + return list, err; + end + + -- shortcut: check if the last item should be trimmed, if so, drop the whole archive + local last = list[#list].when or datetime.parse(list[#list].attr.stamp); + if last <= to_when then + if list.close then + list:close() + end + return datamanager.list_store(username, host, self.store, nil); + end + + -- luacheck: ignore 211/exact + local i, exact = binary_search(list, function(item) + local when = item.when or datetime.parse(item.attr.stamp); + return to_when - when; + end); + if list.close then + list:close() + end + -- TODO if exact then ... off by one? + if i == 1 then return 0; end + local ok, err = datamanager.list_shift(username, host, self.store, i); + if not ok then return ok, err; end + archive_item_count_cache:set(cache_key, nil); -- TODO calculate how many items are left + return i-1; +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); + archive_item_count_cache:set(cache_key, nil); -- nil because we don't check if the following succeeds return datamanager.list_store(username, host, self.store, nil); end + + if use_shift and next(query) == "end" and next(query, "end") == nil then + return self:trim(username, query["end"]); + end + local items, err = datamanager.list_load(username, host, self.store); if not items then if err then diff --git a/plugins/mod_storage_memory.lua b/plugins/mod_storage_memory.lua index 9b0024ab..49f94d1d 100644 --- a/plugins/mod_storage_memory.lua +++ b/plugins/mod_storage_memory.lua @@ -1,15 +1,15 @@ -local serialize = require "util.serialization".serialize; -local array = require "util.array"; -local envload = require "util.envload".envload; -local st = require "util.stanza"; +local serialize = require "prosody.util.serialization".serialize; +local array = require "prosody.util.array"; +local envload = require "prosody.util.envload".envload; +local st = require "prosody.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 new_id = require "prosody.util.id".medium; +local set = require "prosody.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 archive_item_limit = module:get_option_integer("storage_archive_item_limit", 1000, 0); local memory = setmetatable({}, { __index = function(t, k) diff --git a/plugins/mod_storage_sql.lua b/plugins/mod_storage_sql.lua index b3ed7638..3f606160 100644 --- a/plugins/mod_storage_sql.lua +++ b/plugins/mod_storage_sql.lua @@ -1,19 +1,34 @@ -- 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 cache = require "prosody.util.cache"; +local json = require "prosody.util.json"; +local xml_parse = require "prosody.util.xml".parse; +local uuid = require "prosody.util.uuid"; +local resolve_relative_path = require "prosody.util.paths".resolve_relative_path; +local jid_join = require "prosody.util.jid".join; + +local is_stanza = require"prosody.util.stanza".is_stanza; local t_concat = table.concat; +local have_dbisql, dbisql = pcall(require, "prosody.util.sql"); +local have_sqlite, sqlite = pcall(require, "prosody.util.sqlite3"); +if not have_dbisql then + module:log("debug", "Could not load LuaDBI, error was: %s", dbisql) + dbisql = nil; +end +if not have_sqlite then + module:log("debug", "Could not load LuaSQLite3, error was: %s", sqlite) + sqlite = nil; +end +if not (have_dbisql or have_sqlite) then + module:log("error", "LuaDBI or LuaSQLite3 are required for using SQL databases but neither are installed"); + module:log("error", "Please install at least one of LuaDBI and LuaSQLite3. See https://prosody.im/doc/depends"); + error("No SQL library available") +end + local noop = function() end -local unpack = table.unpack or unpack; -- luacheck: ignore 113 +local unpack = table.unpack; local function iterator(result) return function(result_) local row = result_(); @@ -59,9 +74,8 @@ local function deserialize(t, value) end local host = module.host; -local user, store; -local function keyval_store_get() +local function keyval_store_get(user, store) local haveany; local result = {}; local select_sql = [[ @@ -86,7 +100,7 @@ local function keyval_store_get() return result; end end -local function keyval_store_set(data) +local function keyval_store_set(data, user, store) local delete_sql = [[ DELETE FROM "prosody" WHERE "host"=? AND "user"=? AND "store"=? @@ -121,19 +135,15 @@ end local keyval_store = {}; keyval_store.__index = keyval_store; function keyval_store:get(username) - user, store = username, self.store; - local ok, result = engine:transaction(keyval_store_get); + local ok, result = engine:transaction(keyval_store_get, username, self.store); if not ok then - module:log("error", "Unable to read from database %s store for %s: %s", store, username or "<host>", result); + module:log("error", "Unable to read from database %s store for %s: %s", self.store, username or "<host>", result); return nil, result; end return result; end function keyval_store:set(username, data) - user,store = username,self.store; - return engine:transaction(function() - return keyval_store_set(data); - end); + return engine:transaction(keyval_store_set, data, username, self.store); end function keyval_store:users() local ok, result = engine:transaction(function() @@ -150,8 +160,8 @@ end --- Archive store API -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 archive_item_limit = module:get_option_integer("storage_archive_item_limit", nil, 0); +local archive_item_count_cache = cache.new(module:get_option_integer("storage_archive_item_limit_cache_size", 1000, 1)); local item_count_cache_hit = module:measure("item_count_cache_hit", "rate"); local item_count_cache_miss = module:measure("item_count_cache_miss", "rate") @@ -201,6 +211,13 @@ function map_store:set_keys(username, keydatas) ("host","user","store","key","type","value") VALUES (?,?,?,?,?,?); ]]; + local upsert_sql = [[ + INSERT INTO "prosody" + ("host","user","store","key","type","value") + VALUES (?,?,?,?,?,?) + ON CONFLICT ("host", "user","store", "key") + DO UPDATE SET "type"=?, "value"=?; + ]]; local select_extradata_sql = [[ SELECT "type", "value" FROM "prosody" @@ -208,7 +225,10 @@ function map_store:set_keys(username, keydatas) LIMIT 1; ]]; for key, data in pairs(keydatas) do - if type(key) == "string" and key ~= "" then + if type(key) == "string" and key ~= "" and engine.params.driver ~= "MySQL" and data ~= self.remove then + local t, value = assert(serialize(data)); + engine:insert(upsert_sql, host, username or "", self.store, key, t, value, t, value); + elseif type(key) == "string" and key ~= "" then engine:delete(delete_sql, host, username or "", self.store, key); if data ~= self.remove then @@ -291,37 +311,43 @@ 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]; + + if archive_item_limit then + 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 - 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"; + archive_item_count_cache:set(cache_key, item_count); + else + item_count_cache_hit(); 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 + -- FIXME update the schema to allow precision timestamps when = when or os.time(); + if engine.params.driver ~= "SQLite3" then + -- SQLite3 doesn't enforce types :) + when = math.floor(when); + end with = with or ""; local ok, ret = engine:transaction(function() local delete_sql = [[ @@ -334,16 +360,19 @@ function archive_store:append(username, key, value, when, with) VALUES (?,?,?,?,?,?,?,?); ]]; if key then + -- TODO use UPSERT like map store local result = engine:delete(delete_sql, host, user or "", store, key); - if result then + if result and item_count then item_count = item_count - result:affected(); end else - key = uuid.generate(); + key = uuid.v7(); 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); + if item_count then + archive_item_count_cache:set(cache_key, item_count+1); + end return key; end); if not ok then return ok, ret; end @@ -354,12 +383,12 @@ end local function archive_where(query, args, where) -- Time range, inclusive if query.start then - args[#args+1] = query.start + args[#args+1] = math.floor(query.start); where[#where+1] = "\"when\" >= ?" end if query["end"] then - args[#args+1] = query["end"]; + args[#args+1] = math.floor(query["end"]); if query.start then where[#where] = "\"when\" BETWEEN ? AND ?" -- is this inclusive? else @@ -382,8 +411,7 @@ local function archive_where(query, args, where) -- 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) .. ")"; + where[#where + 1] = "\"key\" IN (" .. string.rep("?", nids, ",") .. ")"; for i, id in ipairs(query.ids) do args[nargs+i] = id; end @@ -611,7 +639,7 @@ function archive_store:delete(username, query) LIMIT %s OFFSET ? );]]; if engine.params.driver == "SQLite3" then - if engine._have_delete_limit then + if engine.sqlite_compile_options.enable_update_delete_limit then sql_query = [[ DELETE FROM "prosodyarchive" WHERE %s @@ -630,7 +658,13 @@ function archive_store:delete(username, query) archive_item_count_cache:clear(); else local cache_key = jid_join(username, host, self.store); - archive_item_count_cache:set(cache_key, nil); + if query.start == nil and query.with == nil and query["end"] == nil and query.key == nil and query.ids == nil and query.truncate == nil then + -- All items deleted, count should be zero. + archive_item_count_cache:set(cache_key, 0); + else + -- Not sure how many items left + archive_item_count_cache:set(cache_key, nil); + end end return ok and stmt:affected(), stmt; end @@ -648,10 +682,27 @@ function archive_store:users() return iterator(result); end +local keyvalplus = { + __index = { + -- keyval + get = keyval_store.get; + set = keyval_store.set; + items = keyval_store.users; + -- map + get_key = map_store.get; + set_key = map_store.set; + remove = map_store.remove; + set_keys = map_store.set_keys; + get_key_from_all = map_store.get_all; + delete_key_from_all = map_store.delete_all; + }; +} + local stores = { keyval = keyval_store; map = map_store; archive = archive_store; + ["keyval+"] = keyvalplus; }; --- Implement storage driver API @@ -692,6 +743,7 @@ end local function create_table(engine) -- luacheck: ignore 431/engine + local sql = engine.params.driver == "SQLite3" and sqlite or dbisql; local Table, Column, Index = sql.Table, sql.Column, sql.Index; local ProsodyTable = Table { @@ -702,7 +754,7 @@ local function create_table(engine) -- luacheck: ignore 431/engine Column { name="key", type="TEXT", nullable=false }; Column { name="type", type="TEXT", nullable=false }; Column { name="value", type="MEDIUMTEXT", nullable=false }; - Index { name="prosody_index", "host", "user", "store", "key" }; + Index { name = "prosody_unique_index"; unique = engine.params.driver ~= "MySQL"; "host"; "user"; "store"; "key" }; }; engine:transaction(function() ProsodyTable:create(engine); @@ -732,6 +784,7 @@ end local function upgrade_table(engine, params, apply_changes) -- luacheck: ignore 431/engine local changes = false; if params.driver == "MySQL" then + local sql = dbisql; local success,err = engine:transaction(function() do local result = assert(engine:execute("SHOW COLUMNS FROM \"prosody\" WHERE \"Field\"='value' and \"Type\"='text'")); @@ -799,12 +852,38 @@ local function upgrade_table(engine, params, apply_changes) -- luacheck: ignore success,err = engine:transaction(function() return engine:execute(check_encoding_query, params.database, engine.charset, engine.charset.."_bin"); - end); - if not success then - module:log("error", "Failed to check/upgrade database encoding: %s", err or "unknown error"); - return false; + end); + if not success then + module:log("error", "Failed to check/upgrade database encoding: %s", err or "unknown error"); + return false; + end + else + local indices = {}; + engine:transaction(function () + if params.driver == "SQLite3" then + for row in engine:select [[SELECT "name" FROM "sqlite_schema" WHERE "type"='index' AND "tbl_name"='prosody' AND "name"='prosody_index';]] do + indices[row[1]] = true; + end + elseif params.driver == "PostgreSQL" then + for row in engine:select [[SELECT "indexname" FROM "pg_indexes" WHERE "tablename"='prosody' AND "indexname"='prosody_index';]] do + indices[row[1]] = true; + end + end + end) + if indices["prosody_index"] then + if apply_changes then + local success = engine:transaction(function () + return assert(engine:execute([[DROP INDEX "prosody_index";]])); + end); + if not success then + module:log("error", "Failed to delete obsolete index \"prosody_index\""); + return false; + end + else + changes = true; + end + end end - end return changes; end @@ -831,12 +910,13 @@ end function module.load() local engines = module:shared("/*/sql/connections"); local params = normalize_params(module:get_option("sql", default_params)); + local sql = params.driver == "SQLite3" and sqlite or dbisql; local db_uri = sql.db2uri(params); engine = engines[db_uri]; if not engine then 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 + if module:get_option_boolean("sql_manage_tables", true) then -- Automatically create table, ignore failure (table probably already exists) -- FIXME: we should check in information_schema, etc. create_table(engine); @@ -847,28 +927,74 @@ function module.load() end end if engine.params.driver == "SQLite3" then + local compile_options = {} for row in engine:select("PRAGMA compile_options") do - if row[1] == "ENABLE_UPDATE_DELETE_LIMIT" then - engine._have_delete_limit = true; + local option = row[1]:lower(); + local opt, val = option:match("^([^=]+)=(.*)$"); + compile_options[opt or option] = tonumber(val) or val or true; + end + engine.sqlite_compile_options = compile_options; + + local journal_mode = "delete"; + for row in engine:select[[PRAGMA journal_mode;]] do + journal_mode = row[1]; + end + + -- Note: These things can't be changed with in a transaction. LuaDBI + -- opens a transaction automatically for every statement(?), so this + -- will not work there. + local tune = module:get_option_enum("sqlite_tune", "default", "normal", "fast", "safe"); + if tune == "normal" then + if journal_mode ~= "wal" then + engine:execute("PRAGMA journal_mode=WAL;"); + end + engine:execute("PRAGMA auto_vacuum=FULL;"); + engine:execute("PRAGMA synchronous=NORMAL;") + elseif tune == "fast" then + if journal_mode ~= "wal" then + engine:execute("PRAGMA journal_mode=WAL;"); end + if compile_options.secure_delete then + engine:execute("PRAGMA secure_delete=FAST;"); + end + engine:execute("PRAGMA synchronous=OFF;") + engine:execute("PRAGMA fullfsync=0;") + elseif tune == "safe" then + if journal_mode ~= "delete" then + engine:execute("PRAGMA journal_mode=DELETE;"); + end + engine:execute("PRAGMA synchronous=EXTRA;") + engine:execute("PRAGMA fullfsync=1;") + end + + for row in engine:select[[PRAGMA journal_mode;]] do + journal_mode = row[1]; end + + module:log("debug", "SQLite3 database %q operating with journal_mode=%s", engine.params.database, journal_mode); end + module:set_status("info", "Connected to " .. engine.params.driver); + end, function (engine) -- luacheck: ignore 431/engine + module:set_status("error", "Disconnected from " .. engine.params.driver); end); engines[sql.db2uri(params)] = engine; + else + module:set_status("info", "Using existing engine"); end module:provides("storage", driver); end function module.command(arg) - local config = require "core.configmanager"; - local hi = require "util.human.io"; + local config = require "prosody.core.configmanager"; + local hi = require "prosody.util.human.io"; local command = table.remove(arg, 1); if command == "upgrade" then -- We need to find every unique dburi in the config local uris = {}; for host in pairs(prosody.hosts) do -- luacheck: ignore 431/host local params = normalize_params(config.get(host, "sql") or default_params); + local sql = engine.params.driver == "SQLite3" and sqlite or dbisql; uris[sql.db2uri(params)] = params; end print("We will check and upgrade the following databases:\n"); @@ -884,6 +1010,7 @@ function module.command(arg) -- Upgrade each one for _, params in pairs(uris) do print("Checking "..params.database.."..."); + local sql = params.driver == "SQLite3" and sqlite or dbisql; engine = sql:create_engine(params); upgrade_table(engine, params, true); end diff --git a/plugins/mod_storage_xep0227.lua b/plugins/mod_storage_xep0227.lua index 5c3cf7f6..5b324885 100644 --- a/plugins/mod_storage_xep0227.lua +++ b/plugins/mod_storage_xep0227.lua @@ -2,22 +2,22 @@ local ipairs, pairs = ipairs, pairs; local setmetatable = setmetatable; local tostring = tostring; -local next, unpack = next, table.unpack or unpack; --luacheck: ignore 113/unpack +local next, unpack = next, table.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 jid_bare = require "prosody.util.jid".bare; +local jid_prep = require "prosody.util.jid".prep; +local jid_join = require "prosody.util.jid".join; + +local array = require "prosody.util.array"; +local base64 = require "prosody.util.encodings".base64; +local dt = require "prosody.util.datetime"; +local hex = require "prosody.util.hex"; +local it = require "prosody.util.iterators"; +local paths = require"prosody.util.paths"; +local set = require "prosody.util.set"; +local st = require "prosody.util.stanza"; +local parse_xml_real = require "prosody.util.xml".parse; local lfs = require "lfs"; @@ -80,7 +80,7 @@ local handlers = {}; -- 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_hash_name = module:get_option_enum("password_hash", "SHA-1", "SHA-256"); local scram_properties = set.new({ "server_key", "stored_key", "iteration_count", "salt" }); handlers.accounts = { diff --git a/plugins/mod_time.lua b/plugins/mod_time.lua index 0cd5a4ea..4d9e4f4f 100644 --- a/plugins/mod_time.lua +++ b/plugins/mod_time.lua @@ -6,9 +6,9 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; -local datetime = require "util.datetime".datetime; -local legacy = require "util.datetime".legacy; +local st = require "prosody.util.stanza"; +local datetime = require "prosody.util.datetime".datetime; +local now = require "prosody.util.time".now; -- XEP-0202: Entity Time @@ -18,23 +18,10 @@ local function time_handler(event) local origin, stanza = event.origin, event.stanza; origin.send(st.reply(stanza):tag("time", {xmlns="urn:xmpp:time"}) :tag("tzo"):text("+00:00"):up() -- TODO get the timezone in a platform independent fashion - :tag("utc"):text(datetime())); + :tag("utc"):text(datetime(now()))); return true; end module:hook("iq-get/bare/urn:xmpp:time:time", time_handler); module:hook("iq-get/host/urn:xmpp:time:time", time_handler); --- XEP-0090: Entity Time (deprecated) - -module:add_feature("jabber:iq:time"); - -local function legacy_time_handler(event) - local origin, stanza = event.origin, event.stanza; - origin.send(st.reply(stanza):tag("query", {xmlns="jabber:iq:time"}) - :tag("utc"):text(legacy())); - return true; -end - -module:hook("iq-get/bare/jabber:iq:time:query", legacy_time_handler); -module:hook("iq-get/host/jabber:iq:time:query", legacy_time_handler); diff --git a/plugins/mod_tls.lua b/plugins/mod_tls.lua index afc1653a..b240a64c 100644 --- a/plugins/mod_tls.lua +++ b/plugins/mod_tls.lua @@ -6,14 +6,14 @@ -- COPYING file in the source package for more information. -- -local create_context = require "core.certmanager".create_context; -local rawgetopt = require"core.configmanager".rawget; -local st = require "util.stanza"; +local create_context = require "prosody.core.certmanager".create_context; +local rawgetopt = require"prosody.core.configmanager".rawget; +local st = require "prosody.util.stanza"; -local c2s_require_encryption = module:get_option("c2s_require_encryption", module:get_option("require_encryption", true)); -local s2s_require_encryption = module:get_option("s2s_require_encryption", true); -local allow_s2s_tls = module:get_option("s2s_allow_encryption") ~= false; -local s2s_secure_auth = module:get_option("s2s_secure_auth"); +local c2s_require_encryption = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true)); +local s2s_require_encryption = module:get_option_boolean("s2s_require_encryption", true); +local allow_s2s_tls = module:get_option_boolean("s2s_allow_encryption", true); +local s2s_secure_auth = module:get_option_boolean("s2s_secure_auth", false); if s2s_secure_auth and s2s_require_encryption == false then module:log("warn", "s2s_secure_auth implies s2s_require_encryption, but s2s_require_encryption is set to false"); @@ -62,7 +62,7 @@ function module.load(reload) module:log("debug", "Creating context for s2sout"); -- 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, xmpp_alpn); + ssl_ctx_s2sout, err_s2sout, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s, xmpp_alpn); 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"); @@ -80,6 +80,9 @@ end module:hook_global("config-reloaded", module.load); local function can_do_tls(session) + if session.secure then + return false; + end if session.conn and not session.conn.starttls then if not session.secure then session.log("debug", "Underlying connection does not support STARTTLS"); @@ -125,7 +128,15 @@ end); -- Hook <starttls/> module:hook("stanza/urn:ietf:params:xml:ns:xmpp-tls:starttls", function(event) local origin = event.origin; + origin.starttls = "requested"; if can_do_tls(origin) then + if origin.conn.block_reads then + -- we need to ensure that no data is read anymore, otherwise we could end up in a situation where + -- <proceed/> is sent and the socket receives the TLS handshake (and passes the data to lua) before + -- it is asked to initiate TLS + -- (not with the classical single-threaded server backends) + origin.conn:block_reads() + end (origin.sends2s or origin.send)(starttls_proceed); if origin.destroyed then return end origin:reset_stream(); @@ -166,6 +177,7 @@ module:hook_tag("http://etherx.jabber.org/streams", "features", function (sessio module:log("debug", "%s is not offering TLS", session.to_host); return; end + session.starttls = "initiated"; session.sends2s(starttls_initiate); return true; end @@ -183,7 +195,8 @@ module:hook_tag(xmlns_starttls, "proceed", function (session, stanza) -- luachec if session.type == "s2sout_unauthed" and can_do_tls(session) then module:log("debug", "Proceeding with TLS on s2sout..."); session:reset_stream(); - session.conn:starttls(session.ssl_ctx); + session.starttls = "proceeding" + session.conn:starttls(session.ssl_ctx, session.to_host); session.secure = false; return true; end diff --git a/plugins/mod_tokenauth.lua b/plugins/mod_tokenauth.lua index c04a1aa4..95b0f8d6 100644 --- a/plugins/mod_tokenauth.lua +++ b/plugins/mod_tokenauth.lua @@ -1,82 +1,354 @@ -local id = require "util.id"; -local jid = require "util.jid"; -local base64 = require "util.encodings".base64; +local base64 = require "prosody.util.encodings".base64; +local hashes = require "prosody.util.hashes"; +local id = require "prosody.util.id"; +local jid = require "prosody.util.jid"; +local random = require "prosody.util.random"; +local usermanager = require "prosody.core.usermanager"; +local generate_identifier = require "prosody.util.id".short; -local token_store = module:open_store("auth_tokens", "map"); +local token_store = module:open_store("auth_tokens", "keyval+"); -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 +local access_time_granularity = module:get_option_period("token_auth_access_time_granularity", 60); +local empty_grant_lifetime = module:get_option_period("tokenless_grant_ttl", "2w"); + +local function select_role(username, host, role_name) + if not role_name then return end + local role = usermanager.get_role_by_name(role_name, host); + if not role then return end + if not usermanager.user_can_assume_role(username, host, role.name) then return end + return role; +end + +function create_grant(actor_jid, grant_jid, grant_ttl, grant_data) + grant_jid = jid.prep(grant_jid); + if not actor_jid or actor_jid ~= grant_jid and not jid.compare(grant_jid, actor_jid) then + module:log("debug", "Actor <%s> is not permitted to create a token granting access to JID <%s>", actor_jid, grant_jid); return nil, "not-authorized"; end - local token_username, token_host, token_resource = jid.split(token_jid); + local grant_username, grant_host, grant_resource = jid.split(grant_jid); - if token_host ~= module.host then + if grant_host ~= module.host then return nil, "invalid-host"; end - local token_info = { + local grant_id = id.short(); + local now = os.time(); + + local grant = { + id = grant_id; + 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; - }; + created = now; + expires = grant_ttl and (now + grant_ttl) or nil; + accessed = now; + + jid = grant_jid; + resource = grant_resource; + + data = grant_data; + + -- tokens[<hash-name>..":"..<secret>] = token_info + tokens = {}; + }; + + local ok, err = token_store:set_key(grant_username, grant_id, grant); + if not ok then + return nil, err; + end + + module:fire_event("token-grant-created", { + id = grant_id; + grant = grant; + username = grant_username; + host = grant_host; + }); + + return grant; +end + +function create_token(grant_jid, grant, token_role, token_ttl, token_purpose, token_data) + if (token_data and type(token_data) ~= "table") or (token_purpose and type(token_purpose) ~= "string") then + return nil, "bad-request"; + end + local grant_username, grant_host = jid.split(grant_jid); + if grant_host ~= module.host then + return nil, "invalid-host"; + end + if type(grant) == "string" then -- lookup by id + grant = token_store:get_key(grant_username, grant); + if not grant then return nil; end + end + + if not grant.tokens then return nil, "internal-server-error"; end -- old-style token? + + local now = os.time(); + local expires = grant.expires; -- Default to same expiry as grant + if token_ttl then -- explicit lifetime requested + if expires then + -- Grant has an expiry, so limit to that or shorter + expires = math.min(now + token_ttl, expires); + else + -- Grant never expires, just use whatever expiry is requested for the token + expires = now + token_ttl; + end + end + + local token_info = { + role = token_role; + + created = now; + expires = expires; + purpose = token_purpose; + + data = token_data; }; - 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); + local token_secret = random.bytes(18); + grant.tokens["sha256:"..hashes.sha256(token_secret, true)] = token_info; + + local ok, err = token_store:set_key(grant_username, grant.id, grant); + if not ok then + return nil, err; + end - return token, token_info; + local token_string = "secret-token:"..base64.encode("2;"..grant.id..";"..token_secret..";"..grant.jid); + return token_string, token_info; end local function parse_token(encoded_token) - local token = base64.decode(encoded_token); + if not encoded_token then return nil; end + local encoded_data = encoded_token:match("^secret%-token:(.+)$"); + if not encoded_data then return nil; end + local token = base64.decode(encoded_data); 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_id, token_secret, token_jid = token:match("^2;([^;]+);(..................);(.+)$"); + if not token_id then return nil; end local token_user, token_host = jid.split(token_jid); - return token_id, token_user, token_host; + return token_id, token_user, token_host, token_secret; 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"; +local function clear_expired_grant_tokens(grant, now) + local updated; + now = now or os.time(); + for secret, token_info in pairs(grant.tokens) do + local expires = token_info.expires; + if expires and expires < now then + grant.tokens[secret] = nil; + updated = true; + end + end + return updated; +end + +local function _get_validated_grant_info(username, grant) + if type(grant) == "string" then + grant = token_store:get_key(username, grant); + end + if not grant or not grant.created or not grant.id then return nil; end + + -- Invalidate grants from before last password change + local account_info = usermanager.get_account_info(username, module.host); + local password_updated_at = account_info and account_info.password_updated; + local now = os.time(); + if password_updated_at and grant.created < password_updated_at then + module:log("debug", "Token grant %s of %s issued before last password change, invalidating it now", grant.id, username); + token_store:set_key(username, grant.id, nil); + return nil, "not-authorized"; + elseif grant.expires and grant.expires < now then + module:log("debug", "Token grant %s of %s expired, cleaning up", grant.id, username); + token_store:set_key(username, grant.id, nil); + return nil, "expired"; + end + + if not grant.tokens then + module:log("debug", "Token grant %s of %s without tokens, cleaning up", grant.id, username); + token_store:set_key(username, grant.id, nil); + return nil, "invalid"; + end + + local found_expired = false + for secret_hash, token_info in pairs(grant.tokens) do + if token_info.expires and token_info.expires < now then + module:log("debug", "Token %s of grant %s of %s has expired, cleaning it up", secret_hash:sub(-8), grant.id, username); + grant.tokens[secret_hash] = nil; + found_expired = true; + end + end + + if not grant.expires and next(grant.tokens) == nil and grant.accessed + empty_grant_lifetime < now then + module:log("debug", "Token %s of %s grant has no tokens, discarding", grant.id, username); + token_store:set_key(username, grant.id, nil); + return nil, "expired"; + elseif found_expired then + token_store:set_key(username, grant.id, grant); end + + return grant; +end + +local function _get_validated_token_info(token_id, token_user, token_host, token_secret) 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 + local grant, err = token_store:get_key(token_user, token_id); + if not grant or not grant.tokens then if err then + module:log("error", "Unable to read from token storage: %s", err); return nil, "internal-error"; end + module:log("warn", "Invalid token in storage (%s / %s)", token_user, token_id); + return nil, "not-authorized"; + end + + -- Check provided secret + local secret_hash = "sha256:"..hashes.sha256(token_secret, true); + local token_info = grant.tokens[secret_hash]; + if not token_info then + module:log("debug", "No tokens matched the given secret"); + return nil, "not-authorized"; + end + + -- Check expiry + local now = os.time(); + if token_info.expires and token_info.expires < now then + module:log("debug", "Token has expired, cleaning it up"); + grant.tokens[secret_hash] = nil; + token_store:set_key(token_user, token_id, grant); return nil, "not-authorized"; end - if token_info.expires and token_info.expires < os.time() then + -- Verify grant validity (expiry, etc.) + grant = _get_validated_grant_info(token_user, grant); + if not grant then return nil, "not-authorized"; end - return token_info + -- Update last access time if necessary + local last_accessed = grant.accessed; + if not last_accessed or (now - last_accessed) > access_time_granularity then + grant.accessed = now; + clear_expired_grant_tokens(grant); -- Clear expired tokens while we're here + token_store:set_key(token_user, token_id, grant); + end + + token_info.id = token_id; + token_info.grant = grant; + token_info.jid = grant.jid; + + return token_info; end -function revoke_token(token) - local token_id, token_user, token_host = parse_token(token); +function get_grant_info(username, grant_id) + local grant = _get_validated_grant_info(username, grant_id); + if not grant then return nil; end + + -- Caller is only interested in the grant, no need to expose token stuff to them + grant.tokens = nil; + + return grant; +end + +function get_user_grants(username) + local grants = token_store:get(username); + if not grants then return nil; end + for grant_id, grant in pairs(grants) do + grants[grant_id] = _get_validated_grant_info(username, grant); + end + return grants; +end + +function get_token_info(token) + local token_id, token_user, token_host, token_secret = parse_token(token); + if not token_id then + module:log("warn", "Failed to verify access token: %s", token_user); + return nil, "invalid-token-format"; + end + return _get_validated_token_info(token_id, token_user, token_host, token_secret); +end + +function get_token_session(token, resource) + local token_id, token_user, token_host, token_secret = parse_token(token); if not token_id then + module:log("warn", "Failed to verify access token: %s", token_user); + return nil, "invalid-token-format"; + end + + local token_info, err = _get_validated_token_info(token_id, token_user, token_host, token_secret); + if not token_info then return nil, err; end + + local role = select_role(token_user, token_host, token_info.role); + if not role then return nil, "not-authorized"; end + return { + username = token_user; + host = token_host; + resource = token_info.resource or resource or generate_identifier(); + + role = role; + }; +end + +function revoke_token(token) + local grant_id, token_user, token_host, token_secret = parse_token(token); + if not grant_id then + module:log("warn", "Failed to verify access token: %s", token_user); 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); + local grant, err = _get_validated_grant_info(token_user, grant_id); + if not grant then return grant, err; end + local secret_hash = "sha256:"..hashes.sha256(token_secret, true); + local token_info = grant.tokens[secret_hash]; + if not grant or not token_info then + return nil, "item-not-found"; + end + grant.tokens[secret_hash] = nil; + local ok, err = token_store:set_key(token_user, grant_id, grant); + if not ok then + return nil, err; + end + module:fire_event("token-revoked", { + grant_id = grant_id; + grant = grant; + info = token_info; + username = token_user; + host = token_host; + }); + return true; +end + +function revoke_grant(username, grant_id) + local ok, err = token_store:set_key(username, grant_id, nil); + if not ok then return nil, err; end + module:fire_event("token-grant-revoked", { id = grant_id, username = username, host = module.host }); + return true; +end + +function sasl_handler(auth_provider, purpose, extra) + return function (sasl, token, realm, _authzid) + local token_info, err = get_token_info(token); + if not token_info then + module:log("debug", "SASL handler failed to verify token: %s", err); + return nil, nil, extra; + end + local token_user, token_host, resource = jid.split(token_info.grant.jid); + if realm ~= token_host or (purpose and token_info.purpose ~= purpose) then + return nil, nil, extra; + end + if auth_provider.is_enabled and not auth_provider.is_enabled(token_user) then + return true, false, token_info; + end + sasl.resource = resource; + sasl.token_info = token_info; + return token_user, true, token_info; + end; end + +module:daily("clear expired grants", function() + for username in token_store:items() do + get_user_grants(username); -- clears out expired grants + end +end) diff --git a/plugins/mod_tombstones.lua b/plugins/mod_tombstones.lua index b5a04c9f..e0f1a827 100644 --- a/plugins/mod_tombstones.lua +++ b/plugins/mod_tombstones.lua @@ -1,16 +1,16 @@ -- 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_node = require"util.jid".node; -local st = require "util.stanza"; +local datetime = require "prosody.util.datetime"; +local errors = require "prosody.util.error"; +local jid_node = require"prosody.util.jid".node; +local st = require "prosody.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 graveyard_cache = require "util.cache".new(module:get_option_number("tombstone_cache_size", 1024)); +local graveyard_cache = require "prosody.util.cache".new(module:get_option_integer("tombstone_cache_size", 1024, 1)); -local ttl = module:get_option_number("user_tombstone_expiry", nil); +local ttl = module:get_option_period("user_tombstone_expiry", nil); -- Keep tombstones forever by default -- -- Rationale: diff --git a/plugins/mod_turn_external.lua b/plugins/mod_turn_external.lua index ee50740c..6cdd8c99 100644 --- a/plugins/mod_turn_external.lua +++ b/plugins/mod_turn_external.lua @@ -1,12 +1,12 @@ -local set = require "util.set"; +local set = require "prosody.util.set"; 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 port = module:get_option_integer("turn_external_port", 3478, 1, 65535); +local ttl = module:get_option_period("turn_external_ttl", "1 day"); local tcp = module:get_option_boolean("turn_external_tcp", false); -local tls_port = module:get_option_number("turn_external_tls_port"); +local tls_port = module:get_option_integer("turn_external_tls_port", nil, 1, 65535); if not secret then module:log_status("error", "Failed to initialize: the 'turn_external_secret' option is not set in your configuration"); diff --git a/plugins/mod_uptime.lua b/plugins/mod_uptime.lua index 8a01fb17..9fbf7612 100644 --- a/plugins/mod_uptime.lua +++ b/plugins/mod_uptime.lua @@ -6,7 +6,7 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local start_time = prosody.start_time; module:hook_global("server-started", function() start_time = prosody.start_time end); diff --git a/plugins/mod_user_account_management.lua b/plugins/mod_user_account_management.lua index 130ed089..c2a0e3a2 100644 --- a/plugins/mod_user_account_management.lua +++ b/plugins/mod_user_account_management.lua @@ -7,16 +7,28 @@ -- -local st = require "util.stanza"; -local usermanager_set_password = require "core.usermanager".set_password; -local usermanager_delete_user = require "core.usermanager".delete_user; -local nodeprep = require "util.encodings".stringprep.nodeprep; -local jid_bare = require "util.jid".bare; +local st = require "prosody.util.stanza"; +local usermanager = require "prosody.core.usermanager"; +local nodeprep = require "prosody.util.encodings".stringprep.nodeprep; +local jid_bare, jid_node = import("prosody.util.jid", "bare", "node"); local compat = module:get_option_boolean("registration_compat", true); +local soft_delete_period = module:get_option_period("registration_delete_grace_period"); +local deleted_accounts = module:open_store("accounts_cleanup"); module:add_feature("jabber:iq:register"); +-- Allow us to 'freeze' a session and retrieve properties even after it is +-- destroyed +local function capture_session_properties(session) + return setmetatable({ + id = session.id; + ip = session.ip; + type = session.type; + client_id = session.client_id; + }, { __index = session }); +end + -- Password change and account deletion handler local function handle_registration_stanza(event) local session, stanza = event.origin, event.stanza; @@ -34,6 +46,12 @@ local function handle_registration_stanza(event) if query.tags[1] and query.tags[1].name == "remove" then local username, host = session.username, session.host; + if host ~= module.host then -- Sanity check for safety + module:log("error", "Host mismatch on deletion request (a bug): %s ~= %s", host, module.host); + session.send(st.error_reply(stanza, "cancel", "internal-server-error")); + return true; + end + -- This one weird trick sends a reply to this stanza before the user is deleted local old_session_close = session.close; session.close = function(self, ...) @@ -41,24 +59,57 @@ local function handle_registration_stanza(event) return old_session_close(self, ...); end - local ok, err = usermanager_delete_user(username, host); + local old_session = capture_session_properties(session); - if not ok then - log("debug", "Removing user account %s@%s failed: %s", username, host, err); - session.close = old_session_close; - session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); - return true; - end + if not soft_delete_period then + local ok, err = usermanager.delete_user(username, host); + + if not ok then + log("debug", "Removing user account %s@%s failed: %s", username, host, err); + session.close = old_session_close; + session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); + return true; + end + + log("info", "User removed their account: %s@%s (deleted)", username, host); + module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = old_session }); + else + local ok, err = usermanager.disable_user(username, host, { + reason = "ibr"; + comment = "Deletion requested by user"; + when = os.time(); + }); - log("info", "User removed their account: %s@%s", username, host); - module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session }); + if not ok then + log("debug", "Removing (disabling) user account %s@%s failed: %s", username, host, err); + session.close = old_session_close; + session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); + return true; + end + + local status = { + deleted_at = os.time(); + pending_until = os.time() + soft_delete_period; + client_id = session.client_id; + }; + deleted_accounts:set(username, status); + + log("info", "User removed their account: %s@%s (disabled, pending deletion)", username, host); + module:fire_event("user-deregistered-pending", { + username = username; + host = host; + source = "mod_register"; + session = old_session; + status = status; + }); + end else 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 + if usermanager.set_password(username, password, session.host, session.resource) then session.send(st.reply(stanza)); else -- TODO unable to write file, file may be locked, etc, what's the correct error? @@ -85,3 +136,103 @@ if compat then end); end +-- This improves UX of soft-deleted accounts by informing the user that the +-- account has been deleted, rather than just disabled. They can e.g. contact +-- their admin if this was a mistake. +module:hook("authentication-failure", function (event) + if event.condition ~= "account-disabled" then return; end + local session = event.session; + local sasl_handler = session and session.sasl_handler; + if sasl_handler.username then + local status = deleted_accounts:get(sasl_handler.username); + if status then + event.text = "Account deleted"; + end + end +end, -1000); + +function restore_account(username) + local pending, pending_err = deleted_accounts:get(username); + if not pending then + return nil, pending_err or "Account not pending deletion"; + end + local account_info, err = usermanager.get_account_info(username, module.host); + if not account_info then + return nil, "Couldn't fetch account info: "..err; + end + local forget_ok, forget_err = deleted_accounts:set(username, nil); + if not forget_ok then + return nil, "Couldn't remove account from deletion queue: "..forget_err; + end + local enable_ok, enable_err = usermanager.enable_user(username, module.host); + if not enable_ok then + return nil, "Removed account from deletion queue, but couldn't enable it: "..enable_err; + end + return true, "Account restored"; +end + +-- Automatically clear pending deletion if an account is re-enabled +module:context("*"):hook("user-enabled", function (event) + if event.host ~= module.host then return; end + deleted_accounts:set(event.username, nil); +end); + +local cleanup_time = module:measure("cleanup", "times"); + +function cleanup_soft_deleted_accounts() + local cleanup_done = cleanup_time(); + local success, fail, restored, pending = 0, 0, 0, 0; + + for username in deleted_accounts:users() do + module:log("debug", "Processing account cleanup for '%s'", username); + local account_info, account_info_err = usermanager.get_account_info(username, module.host); + if not account_info then + module:log("warn", "Unable to process delayed deletion of user '%s': %s", username, account_info_err); + fail = fail + 1; + else + if account_info.enabled == false then + local meta = deleted_accounts:get(username); + if meta.pending_until <= os.time() then + local ok, err = usermanager.delete_user(username, module.host); + if not ok then + module:log("warn", "Unable to process delayed deletion of user '%s': %s", username, err); + fail = fail + 1; + else + success = success + 1; + deleted_accounts:set(username, nil); + module:log("debug", "Deleted account '%s' successfully", username); + module:fire_event("user-deregistered", { username = username, host = module.host, source = "mod_register" }); + end + else + pending = pending + 1; + end + else + module:log("warn", "Account '%s' is not disabled, removing from deletion queue", username); + restored = restored + 1; + end + end + end + + module:log("debug", "%d accounts scheduled for future deletion", pending); + + if success > 0 or fail > 0 then + module:log("info", "Completed account cleanup - %d accounts deleted (%d failed, %d restored, %d pending)", success, fail, restored, pending); + end + cleanup_done(); +end + +module:daily("Remove deleted accounts", cleanup_soft_deleted_accounts); + +--- shell command +module:add_item("shell-command", { + section = "user"; + name = "restore"; + desc = "Restore a user account scheduled for deletion"; + args = { + { name = "jid", type = "string" }; + }; + host_selector = "jid"; + handler = function (self, jid) --luacheck: ignore 212/self + return restore_account(jid_node(jid)); + end; +}); diff --git a/plugins/mod_vcard.lua b/plugins/mod_vcard.lua index c3d6fb8b..ea6839bb 100644 --- a/plugins/mod_vcard.lua +++ b/plugins/mod_vcard.lua @@ -6,8 +6,8 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza" -local jid_split = require "util.jid".split; +local st = require "prosody.util.stanza" +local jid_split = require "prosody.util.jid".split; local vcards = module:open_store(); diff --git a/plugins/mod_vcard4.lua b/plugins/mod_vcard4.lua index 04dbca9e..1917f609 100644 --- a/plugins/mod_vcard4.lua +++ b/plugins/mod_vcard4.lua @@ -1,5 +1,5 @@ -local st = require "util.stanza" -local jid_split = require "util.jid".split; +local st = require "prosody.util.stanza" +local jid_split = require "prosody.util.jid".split; local mod_pep = module:depends("pep"); diff --git a/plugins/mod_vcard_legacy.lua b/plugins/mod_vcard_legacy.lua index 107f20da..eb392309 100644 --- a/plugins/mod_vcard_legacy.lua +++ b/plugins/mod_vcard_legacy.lua @@ -1,10 +1,10 @@ -local st = require "util.stanza"; -local jid_split = require "util.jid".split; +local st = require "prosody.util.stanza"; +local jid_split = require "prosody.util.jid".split; local mod_pep = module:depends("pep"); -local sha1 = require "util.hashes".sha1; -local base64_decode = require "util.encodings".base64.decode; +local sha1 = require "prosody.util.hashes".sha1; +local base64_decode = require "prosody.util.encodings".base64.decode; local vcards = module:open_store("vcard"); diff --git a/plugins/mod_version.lua b/plugins/mod_version.lua index 1d24001c..72b13387 100644 --- a/plugins/mod_version.lua +++ b/plugins/mod_version.lua @@ -6,7 +6,7 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; module:add_feature("jabber:iq:version"); @@ -20,9 +20,14 @@ if not module:get_option_boolean("hide_os_type") then platform = "Windows"; else local os_version_command = module:get_option_string("os_version_command"); - local ok, pposix = pcall(require, "util.pposix"); + local ok, pposix = pcall(require, "prosody.util.pposix"); if not os_version_command and (ok and pposix and pposix.uname) then - platform = pposix.uname().sysname; + local uname, err = pposix.uname(); + if not uname then + module:log("debug", "Could not retrieve OS name: %s", err); + else + platform = uname.sysname; + end end if not platform then local uname = io.popen(os_version_command or "uname"); diff --git a/plugins/mod_watchregistrations.lua b/plugins/mod_watchregistrations.lua index 825b8a73..d433d732 100644 --- a/plugins/mod_watchregistrations.lua +++ b/plugins/mod_watchregistrations.lua @@ -8,14 +8,14 @@ local host = module:get_host(); -local jid_prep = require "util.jid".prep; +local jid_prep = require "prosody.util.jid".prep; local registration_watchers = module:get_option_set("registration_watchers", module:get_option("admins", {})) / jid_prep; local registration_from = module:get_option_string("registration_from", host); local registration_notification = module:get_option_string("registration_notification", "User $username just registered on $host from $ip"); -local msg_type = module:get_option_string("registration_notification_type", "chat"); +local msg_type = module:get_option_enum("registration_notification_type", "chat", "normal", "headline"); -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; module:hook("user-registered", function (user) module:log("debug", "Notifying of new registration"); diff --git a/plugins/mod_websocket.lua b/plugins/mod_websocket.lua index f0caa968..7120f3cc 100644 --- a/plugins/mod_websocket.lua +++ b/plugins/mod_websocket.lua @@ -8,19 +8,19 @@ module:set_global(); -local add_task = require "util.timer".add_task; -local add_filter = require "util.filters".add_filter; -local sha1 = require "util.hashes".sha1; -local base64 = require "util.encodings".base64.encode; -local st = require "util.stanza"; -local parse_xml = require "util.xml".parse; -local contains_token = require "util.http".contains_token; -local portmanager = require "core.portmanager"; -local sm_destroy_session = require"core.sessionmanager".destroy_session; +local add_task = require "prosody.util.timer".add_task; +local add_filter = require "prosody.util.filters".add_filter; +local sha1 = require "prosody.util.hashes".sha1; +local base64 = require "prosody.util.encodings".base64.encode; +local st = require "prosody.util.stanza"; +local parse_xml = require "prosody.util.xml".parse; +local contains_token = require "prosody.util.http".contains_token; +local portmanager = require "prosody.core.portmanager"; +local sm_destroy_session = require"prosody.core.sessionmanager".destroy_session; local log = module._log; -local dbuffer = require "util.dbuffer"; +local dbuffer = require "prosody.util.dbuffer"; -local websocket_frames = require"net.websocket.frames"; +local websocket_frames = require"prosody.net.websocket.frames"; local parse_frame = websocket_frames.parse; local build_frame = websocket_frames.build; local build_close = websocket_frames.build_close; @@ -28,10 +28,10 @@ local parse_close = websocket_frames.parse_close; local t_concat = table.concat; -local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024 * 256); -local frame_buffer_limit = module:get_option_number("websocket_frame_buffer_limit", 2 * stanza_size_limit); -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 stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024 * 256, 10000); +local frame_buffer_limit = module:get_option_integer("websocket_frame_buffer_limit", 2 * stanza_size_limit, 0); +local frame_fragment_limit = module:get_option_integer("websocket_frame_fragment_limit", 8, 0); +local stream_close_timeout = module:get_option_period("c2s_close_timeout", 5); local consider_websocket_secure = module:get_option_boolean("consider_websocket_secure"); local cross_domain = module:get_option("cross_domain_websocket"); if cross_domain ~= nil then @@ -370,6 +370,6 @@ function module.add_host(module) module:hook("c2s-read-timeout", keepalive, -0.9); end -if require"core.modulemanager".get_modules_for_host("*"):contains(module.name) then +if require"prosody.core.modulemanager".get_modules_for_host("*"):contains(module.name) then module:add_host(); end diff --git a/plugins/mod_welcome.lua b/plugins/mod_welcome.lua index f6b13df5..0dd0c069 100644 --- a/plugins/mod_welcome.lua +++ b/plugins/mod_welcome.lua @@ -9,7 +9,7 @@ local host = module:get_host(); local welcome_text = module:get_option_string("welcome_message", "Hello $username, welcome to the $host IM server!"); -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; module:hook("user-registered", function (user) diff --git a/plugins/muc/hats.lib.lua b/plugins/muc/hats.lib.lua index 358e5100..492dc72c 100644 --- a/plugins/muc/hats.lib.lua +++ b/plugins/muc/hats.lib.lua @@ -1,7 +1,10 @@ -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local muc_util = module:require "muc/util"; -local xmlns_hats = "xmpp:prosody.im/protocol/hats:1"; +local hats_compat = module:get_option_boolean("muc_hats_compat", true); -- COMPAT for pre-XEP namespace, TODO reconsider default for next release + +local xmlns_hats_legacy = "xmpp:prosody.im/protocol/hats:1"; +local xmlns_hats = "urn:xmpp:hats:0"; -- Strip any hats claimed by the client (to prevent spoofing) muc_util.add_filtered_namespace(xmlns_hats); @@ -13,14 +16,26 @@ module:hook("muc-build-occupant-presence", function (event) local hats = aff_data and aff_data.hats; if not hats then return; end local hats_el; + local legacy_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(); + + if hats_compat then + if not hats_el then + legacy_hats_el = st.stanza("hats", { xmlns = xmlns_hats_legacy }); + end + legacy_hats_el:tag("hat", { uri = hat_id, title = hat_data.title }):up(); + end end end if not hats_el then return; end event.stanza:add_direct_child(hats_el); + + if legacy_hats_el then + event.stanza:add_direct_child(legacy_hats_el); + end end); diff --git a/plugins/muc/hidden.lib.lua b/plugins/muc/hidden.lib.lua index 153df21a..d24fa47e 100644 --- a/plugins/muc/hidden.lib.lua +++ b/plugins/muc/hidden.lib.lua @@ -8,7 +8,7 @@ -- local restrict_public = not module:get_option_boolean("muc_room_allow_public", true); -local um_is_admin = require "core.usermanager".is_admin; +module:default_permission(restrict_public and "prosody:admin" or "prosody:registered", ":create-public-room"); local function get_hidden(room) return room._data.hidden; @@ -22,8 +22,8 @@ local function set_hidden(room, hidden) end module:hook("muc-config-form", function(event) - if restrict_public and not um_is_admin(event.actor, module.host) then - -- Don't show option if public rooms are restricted and user is not admin of this host + if not module:may(":create-public-room", event.actor) then + -- Hide config option if this user is not allowed to create public rooms return; end table.insert(event.form, { @@ -36,7 +36,7 @@ module:hook("muc-config-form", function(event) end, 100-9); module:hook("muc-config-submitted/muc#roomconfig_publicroom", function(event) - if restrict_public and not um_is_admin(event.actor, module.host) then + if not module:may(":create-public-room", event.actor) then return; -- Not allowed end if set_hidden(event.room, not event.value) then diff --git a/plugins/muc/history.lib.lua b/plugins/muc/history.lib.lua index 075b1890..005bd1d8 100644 --- a/plugins/muc/history.lib.lua +++ b/plugins/muc/history.lib.lua @@ -8,11 +8,11 @@ -- local gettime = os.time; -local datetime = require "util.datetime"; -local st = require "util.stanza"; +local datetime = require "prosody.util.datetime"; +local st = require "prosody.util.stanza"; local default_history_length = 20; -local max_history_length = module:get_option_number("max_history_messages", math.huge); +local max_history_length = module:get_option_integer("max_history_messages", math.huge, 0); local function set_max_history_length(_max_history_length) max_history_length = _max_history_length or math.huge; diff --git a/plugins/muc/lock.lib.lua b/plugins/muc/lock.lib.lua index 32f2647b..bb5bf82b 100644 --- a/plugins/muc/lock.lib.lua +++ b/plugins/muc/lock.lib.lua @@ -7,10 +7,10 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local lock_rooms = module:get_option_boolean("muc_room_locking", true); -local lock_room_timeout = module:get_option_number("muc_room_lock_timeout", 300); +local lock_room_timeout = module:get_option_period("muc_room_lock_timeout", "5 minutes"); local function lock(room) module:fire_event("muc-room-locked", {room = room;}); diff --git a/plugins/muc/members_only.lib.lua b/plugins/muc/members_only.lib.lua index b10dc120..4f4e88fa 100644 --- a/plugins/muc/members_only.lib.lua +++ b/plugins/muc/members_only.lib.lua @@ -7,7 +7,7 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local muc_util = module:require "muc/util"; local valid_affiliations = muc_util.valid_affiliations; diff --git a/plugins/muc/mod_muc.lua b/plugins/muc/mod_muc.lua index 5873b1a2..1dc99f07 100644 --- a/plugins/muc/mod_muc.lua +++ b/plugins/muc/mod_muc.lua @@ -86,21 +86,26 @@ 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 restrict_pm = module:require "muc/restrict_pm"; +room_mt.get_allow_pm = restrict_pm.get_allow_pm; +room_mt.set_allow_pm = restrict_pm.set_allow_pm; +room_mt.get_allow_modpm = restrict_pm.get_allow_modpm; +room_mt.set_allow_modpm = restrict_pm.set_allow_modpm; + 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; +room_mt.get_valid_broadcast_roles = presence_broadcast.get_valid_broadcast_roles; -- FIXME doesn't exist in the library 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"; -local um_is_admin = require "core.usermanager".is_admin; +local jid_split = require "prosody.util.jid".split; +local jid_prep = require "prosody.util.jid".prep; +local jid_bare = require "prosody.util.jid".bare; +local st = require "prosody.util.stanza"; +local cache = require "prosody.util.cache"; module:require "muc/config_form_sections"; @@ -111,21 +116,26 @@ module:depends "muc_unique" module:require "muc/hats"; module:require "muc/lock"; -local function is_admin(jid) - return um_is_admin(jid, module.host); -end +module:default_permissions("prosody:admin", { + ":automatic-ownership"; + ":create-room"; + ":recreate-destroyed-room"; +}); +module:default_permissions("prosody:guest", { + ":list-rooms"; +}); -if module:get_option_boolean("component_admins_as_room_owners", true) then +if module:get_option_boolean("component_admins_as_room_owners", false) then -- Monkey patch to make server admins room owners local _get_affiliation = room_mt.get_affiliation; function room_mt:get_affiliation(jid) - if is_admin(jid) then return "owner"; end + if module:could(":automatic-ownership", jid) then return "owner"; end return _get_affiliation(self, jid); end local _set_affiliation = room_mt.set_affiliation; function room_mt:set_affiliation(actor, jid, affiliation, reason, data) - if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end + if affiliation ~= "owner" and module:could(":automatic-ownership", jid) then return nil, "modify", "not-acceptable"; end return _set_affiliation(self, actor, jid, affiliation, reason, data); end end @@ -158,8 +168,8 @@ local function room_save(room, forced, savestate) end end -local max_rooms = module:get_option_number("muc_max_rooms"); -local max_live_rooms = module:get_option_number("muc_room_cache_size", 100); +local max_rooms = module:get_option_integer("muc_max_rooms", nil, 0); +local max_live_rooms = module:get_option_integer("muc_room_cache_size", 100, 1); local room_hit = module:measure("room_hit", "rate"); local room_miss = module:measure("room_miss", "rate") @@ -281,15 +291,16 @@ local function set_room_defaults(room, lang) room:set_public(module:get_option_boolean("muc_room_default_public", false)); room:set_persistent(module:get_option_boolean("muc_room_default_persistent", room:get_persistent())); room:set_members_only(module:get_option_boolean("muc_room_default_members_only", room:get_members_only())); - room:set_allow_member_invites(module:get_option_boolean("muc_room_default_allow_member_invites", - room:get_allow_member_invites())); + room:set_allow_member_invites(module:get_option_boolean("muc_room_default_allow_member_invites", room:get_allow_member_invites())); room:set_moderated(module:get_option_boolean("muc_room_default_moderated", room:get_moderated())); - room:set_whois(module:get_option_boolean("muc_room_default_public_jids", - room:get_whois() == "anyone") and "anyone" or "moderators"); + room:set_whois(module:get_option_boolean("muc_room_default_public_jids", room:get_whois() == "anyone") and "anyone" or "moderators"); 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_historylength(module:get_option_integer("muc_room_default_history_length", room:get_historylength(), 0)); 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())); + room:set_presence_broadcast(module:get_option_enum("muc_room_default_presence_broadcast", room:get_presence_broadcast(), "visitor", "participant", + "moderator")); + room:set_allow_pm(module:get_option_enum("muc_room_default_allow_pm", room:get_allow_pm(), "visitor", "participant", "moderator")); + room:set_allow_modpm(module:get_option_boolean("muc_room_default_always_allow_moderator_pms", room:get_allow_modpm())); end function create_room(room_jid, config) @@ -350,8 +361,12 @@ function each_room(live_only) end module:hook("host-disco-items", function(event) - local reply = event.reply; module:log("debug", "host-disco-items called"); + if not module:could(":list-rooms", event) then + module:log("debug", "Returning empty room list to unauthorized request"); + return; + end + local reply = event.reply; 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 @@ -388,7 +403,7 @@ end); if module:get_option_boolean("muc_tombstones", true) then - local ttl = module:get_option_number("muc_tombstone_expiry", 86400 * 31); + local ttl = module:get_option_period("muc_tombstone_expiry", "31 days"); module:hook("muc-room-destroyed",function(event) local room = event.room; @@ -412,26 +427,15 @@ if module:get_option_boolean("muc_tombstones", true) then end, -10); end -do - local restrict_room_creation = module:get_option("restrict_room_creation"); - if restrict_room_creation == true then - restrict_room_creation = "admin"; - end - if restrict_room_creation then - local host_suffix = module.host:gsub("^[^%.]+%.", ""); - module:hook("muc-room-pre-create", function(event) - local origin, stanza = event.origin, event.stanza; - local user_jid = stanza.attr.from; - if not is_admin(user_jid) and not ( - 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", module.host)); - return true; - end - end); +local restrict_room_creation = module:get_option_enum("restrict_room_creation", false, true, "local"); +module:default_permission(restrict_room_creation == true and "prosody:admin" or "prosody:registered", ":create-room"); +module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + if restrict_room_creation ~= false and not module:may(":create-room", event) then + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted", module.host)); + return true; end -end +end); for event_name, method in pairs { -- Normal room interactions @@ -465,7 +469,7 @@ for event_name, method in pairs { if room and room._data.destroyed then if room._data.locked < os.time() - or (is_admin(stanza.attr.from) and stanza.name == "presence" and stanza.attr.type == nil) then + or (module:may(":recreate-destroyed-room", event) and stanza.name == "presence" and stanza.attr.type == nil) then -- Allow the room to be recreated by admin or after time has passed delete_room(room); room = nil; @@ -516,10 +520,10 @@ do -- Ad-hoc commands module:depends "adhoc"; 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; + local adhoc_initial = require "prosody.util.adhoc".new_initial_data_form; + local adhoc_simple = require "prosody.util.adhoc".new_simple_form; + local array = require "prosody.util.array"; + local dataforms_new = require "prosody.util.dataforms".new; local destroy_rooms_layout = dataforms_new { title = "Destroy rooms"; diff --git a/plugins/muc/muc.lib.lua b/plugins/muc/muc.lib.lua index 01427dbe..b8f276cf 100644 --- a/plugins/muc/muc.lib.lua +++ b/plugins/muc/muc.lib.lua @@ -12,18 +12,18 @@ local pairs = pairs; local next = next; local setmetatable = setmetatable; -local dataform = require "util.dataforms"; -local iterators = require "util.iterators"; -local jid_split = require "util.jid".split; -local jid_bare = require "util.jid".bare; -local jid_prep = require "util.jid".prep; -local jid_join = require "util.jid".join; -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 hmac_sha256 = require "util.hashes".hmac_sha256; -local new_id = require "util.id".medium; +local dataform = require "prosody.util.dataforms"; +local iterators = require "prosody.util.iterators"; +local jid_split = require "prosody.util.jid".split; +local jid_bare = require "prosody.util.jid".bare; +local jid_prep = require "prosody.util.jid".prep; +local jid_join = require "prosody.util.jid".join; +local jid_resource = require "prosody.util.jid".resource; +local resourceprep = require "prosody.util.encodings".stringprep.resourceprep; +local st = require "prosody.util.stanza"; +local base64 = require "prosody.util.encodings".base64; +local hmac_sha256 = require "prosody.util.hashes".hmac_sha256; +local new_id = require "prosody.util.id".medium; local log = module._log; @@ -1079,7 +1079,10 @@ function room_mt:handle_admin_query_set_command(origin, stanza) local reason = item:get_child_text("reason"); local success, errtype, err if item.attr.affiliation and item.attr.jid and not item.attr.role then - local registration_data; + local registration_data = self:get_affiliation_data(item.attr.jid) or {}; + if reason then + registration_data.reason = reason; + end if item.attr.nick then local room_nick = self.jid.."/"..item.attr.nick; local existing_occupant = self:get_occupant_by_nick(room_nick); @@ -1088,7 +1091,7 @@ function room_mt:handle_admin_query_set_command(origin, stanza) self:set_role(true, room_nick, nil, "This nickname is reserved"); end module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation); - registration_data = { reserved_nickname = item.attr.nick }; + registration_data.reserved_nickname = item.attr.nick; end success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data); elseif item.attr.role and item.attr.nick and not item.attr.affiliation then @@ -1119,9 +1122,13 @@ function room_mt:handle_admin_query_get_command(origin, stanza) if (affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank) or (self:get_members_only() and self:get_whois() == "anyone" and affiliation_rank >= valid_affiliations.member) then local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); - for jid in self:each_affiliation(_aff or "none") do + for jid, _, data in self:each_affiliation(_aff or "none") do local nick = self:get_registered_nick(jid); - reply:tag("item", {affiliation = _aff, jid = jid, nick = nick }):up(); + reply:tag("item", {affiliation = _aff, jid = jid, nick = nick }); + if data and data.reason then + reply:text_tag("reason", data.reason); + end + reply:up(); end origin.send(reply:up()); return true; diff --git a/plugins/muc/occupant.lib.lua b/plugins/muc/occupant.lib.lua index 8fe4bbdf..a7d9cef7 100644 --- a/plugins/muc/occupant.lib.lua +++ b/plugins/muc/occupant.lib.lua @@ -1,6 +1,6 @@ local pairs = pairs; local setmetatable = setmetatable; -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local util = module:require "muc/util"; local function get_filtered_presence(stanza) diff --git a/plugins/muc/occupant_id.lib.lua b/plugins/muc/occupant_id.lib.lua index 1d310b3d..b1081c9b 100644 --- a/plugins/muc/occupant_id.lib.lua +++ b/plugins/muc/occupant_id.lib.lua @@ -4,9 +4,9 @@ -- (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 uuid = require "prosody.util.uuid"; +local hmac_sha256 = require "prosody.util.hashes".hmac_sha256; +local b64encode = require "prosody.util.encodings".base64.encode; local xmlns_occupant_id = "urn:xmpp:occupant-id:0"; diff --git a/plugins/muc/password.lib.lua b/plugins/muc/password.lib.lua index dd3cb658..9d3c0cca 100644 --- a/plugins/muc/password.lib.lua +++ b/plugins/muc/password.lib.lua @@ -7,7 +7,7 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local function get_password(room) return room._data.password; diff --git a/plugins/muc/persistent.lib.lua b/plugins/muc/persistent.lib.lua index c3b16ea4..29ed7784 100644 --- a/plugins/muc/persistent.lib.lua +++ b/plugins/muc/persistent.lib.lua @@ -8,7 +8,10 @@ -- local restrict_persistent = not module:get_option_boolean("muc_room_allow_persistent", true); -local um_is_admin = require "core.usermanager".is_admin; +module:default_permission( + restrict_persistent and "prosody:admin" or "prosody:registered", + ":create-persistent-room" +); local function get_persistent(room) return room._data.persistent; @@ -22,8 +25,8 @@ local function set_persistent(room, persistent) end module:hook("muc-config-form", function(event) - if restrict_persistent and not um_is_admin(event.actor, module.host) then - -- Don't show option if hidden rooms are restricted and user is not admin of this host + if not module:may(":create-persistent-room", event.actor) then + -- Hide config option if this user is not allowed to create persistent rooms return; end table.insert(event.form, { @@ -36,7 +39,7 @@ module:hook("muc-config-form", function(event) end, 100-5); module:hook("muc-config-submitted/muc#roomconfig_persistentroom", function(event) - if restrict_persistent and not um_is_admin(event.actor, module.host) then + if not module:may(":create-persistent-room", event.actor) then return; -- Not allowed end if set_persistent(event.room, event.value) then diff --git a/plugins/muc/presence_broadcast.lib.lua b/plugins/muc/presence_broadcast.lib.lua index 82a89fee..721c47aa 100644 --- a/plugins/muc/presence_broadcast.lib.lua +++ b/plugins/muc/presence_broadcast.lib.lua @@ -7,7 +7,7 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; +local st = require "prosody.util.stanza"; local valid_roles = { "none", "visitor", "participant", "moderator" }; local default_broadcast = { diff --git a/plugins/muc/register.lib.lua b/plugins/muc/register.lib.lua index 84045f33..82ccb8ea 100644 --- a/plugins/muc/register.lib.lua +++ b/plugins/muc/register.lib.lua @@ -1,8 +1,8 @@ -local jid_bare = require "util.jid".bare; -local jid_resource = require "util.jid".resource; -local resourceprep = require "util.encodings".stringprep.resourceprep; -local st = require "util.stanza"; -local dataforms = require "util.dataforms"; +local jid_bare = require "prosody.util.jid".bare; +local jid_resource = require "prosody.util.jid".resource; +local resourceprep = require "prosody.util.encodings".stringprep.resourceprep; +local st = require "prosody.util.stanza"; +local dataforms = require "prosody.util.dataforms"; local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", false); @@ -94,8 +94,10 @@ local function enforce_nick_policy(event) local nick = get_registered_nick(room, jid_bare(stanza.attr.from)); if nick then if event.occupant then + -- someone is joining, force their nickname to the registered one event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick; elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then + -- someone is trying to change nickname to something other than their registered nickname, can't have that 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", nil, room.jid):up(); origin.send(reply); diff --git a/plugins/muc/request.lib.lua b/plugins/muc/request.lib.lua index 4e95fdc3..f3786595 100644 --- a/plugins/muc/request.lib.lua +++ b/plugins/muc/request.lib.lua @@ -7,14 +7,14 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; -local jid_resource = require "util.jid".resource; +local st = require "prosody.util.stanza"; +local jid_resource = require "prosody.util.jid".resource; module:hook("muc-disco#info", function(event) event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#request"}):up(); end); -local voice_request_form = require "util.dataforms".new({ +local voice_request_form = require "prosody.util.dataforms".new({ title = "Voice Request"; { name = "FORM_TYPE"; diff --git a/plugins/muc/restrict_pm.lib.lua b/plugins/muc/restrict_pm.lib.lua new file mode 100644 index 00000000..e0b25cc8 --- /dev/null +++ b/plugins/muc/restrict_pm.lib.lua @@ -0,0 +1,119 @@ +-- Based on code from mod_muc_restrict_pm in prosody-modules@d82c0383106a +-- by Nicholas George <wirlaburla@worlio.com> + +local st = require "util.stanza"; +local muc_util = module:require "muc/util"; +local valid_roles = muc_util.valid_roles; + +-- COMPAT w/ prosody-modules allow_pm +local compat_map = { + everyone = "visitor"; + participants = "participant"; + moderators = "moderator"; + members = "affiliated"; +}; + +local function get_allow_pm(room) + local val = room._data.allow_pm; + return compat_map[val] or val or "visitor"; +end + +local function set_allow_pm(room, val) + if get_allow_pm(room) == val then return false; end + room._data.allow_pm = val; + return true; +end + +local function get_allow_modpm(room) + return room._data.allow_modpm or false; +end + +local function set_allow_modpm(room, val) + if get_allow_modpm(room) == val then return false; end + room._data.allow_modpm = val; + return true; +end + +module:hook("muc-config-form", function(event) + local pmval = get_allow_pm(event.room); + table.insert(event.form, { + name = 'muc#roomconfig_allowpm'; + type = 'list-single'; + label = 'Allow private messages from'; + options = { + { value = 'visitor', label = 'Everyone', default = pmval == 'visitor' }; + { value = 'participant', label = 'Participants', default = pmval == 'participant' }; + { value = 'moderator', label = 'Moderators', default = pmval == 'moderator' }; + { value = 'affiliated', label = "Members", default = pmval == "affiliated" }; + { value = 'none', label = 'No one', default = pmval == 'none' }; + } + }); + table.insert(event.form, { + name = '{xmpp:prosody.im}muc#allow_modpm'; + type = 'boolean'; + label = 'Always allow private messages to moderators'; + value = get_allow_modpm(event.room) + }); +end); + +module:hook("muc-config-submitted/muc#roomconfig_allowpm", function(event) + if set_allow_pm(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +module:hook("muc-config-submitted/{xmpp:prosody.im}muc#allow_modpm", function(event) + if set_allow_modpm(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +local who_restricted = { + none = "in this group"; + participant = "from guests"; + moderator = "from non-moderators"; + affiliated = "from non-members"; +}; + +module:hook("muc-private-message", function(event) + local stanza, room = event.stanza, event.room; + local from_occupant = room:get_occupant_by_nick(stanza.attr.from); + local to_occupant = room:get_occupant_by_nick(stanza.attr.to); + + -- To self is always okay + if to_occupant.bare_jid == from_occupant.bare_jid then return; end + + if get_allow_modpm(room) then + if to_occupant and to_occupant.role == 'moderator' + or from_occupant and from_occupant.role == "moderator" then + return; -- Allow to/from moderators + end + end + + local pmval = get_allow_pm(room); + + if pmval ~= "none" then + if pmval == "affiliated" and room:get_affiliation(from_occupant.bare_jid) then + return; -- Allow from affiliated users + elseif valid_roles[from_occupant.role] >= valid_roles[pmval] then + module:log("debug", "Allowing PM: %s(%d) >= %s(%d)", from_occupant.role, valid_roles[from_occupant.role], pmval, valid_roles[pmval]); + return; -- Allow from a permitted role + end + end + + local msg = ("Private messages are restricted %s"):format(who_restricted[pmval]); + module:log("debug", "Blocking PM from %s %s: %s", from_occupant.role, stanza.attr.from, msg); + + room:route_to_occupant( + from_occupant, + st.error_reply(stanza, "cancel", "policy-violation", msg, room.jid) + ); + return false; +end, 1); + +return { + get_allow_pm = get_allow_pm; + set_allow_pm = set_allow_pm; + get_allow_modpm = get_allow_modpm; + set_allow_modpm = set_allow_modpm; +}; diff --git a/plugins/muc/subject.lib.lua b/plugins/muc/subject.lib.lua index 3230817c..047ea2df 100644 --- a/plugins/muc/subject.lib.lua +++ b/plugins/muc/subject.lib.lua @@ -7,8 +7,8 @@ -- COPYING file in the source package for more information. -- -local st = require "util.stanza"; -local dt = require "util.datetime"; +local st = require "prosody.util.stanza"; +local dt = require "prosody.util.datetime"; local muc_util = module:require "muc/util"; local valid_roles = muc_util.valid_roles; |