diff options
Diffstat (limited to 'plugins')
66 files changed, 2277 insertions, 1214 deletions
diff --git a/plugins/adhoc/adhoc.lib.lua b/plugins/adhoc/adhoc.lib.lua index 0b910299..0c61a636 100644 --- a/plugins/adhoc/adhoc.lib.lua +++ b/plugins/adhoc/adhoc.lib.lua @@ -21,7 +21,13 @@ local function _cmdtag(desc, status, sessionid, action) end function _M.new(name, node, handler, permission) - return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = (permission or "user") }; + if not permission then + error "adhoc.new() expects a permission argument, none given" + end + if permission == "user" then + error "the permission mode 'user' has been renamed 'any', please update your code" + end + return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = permission }; end function _M.handle_cmd(command, origin, stanza) diff --git a/plugins/adhoc/mod_adhoc.lua b/plugins/adhoc/mod_adhoc.lua index bf1775b4..188d05e8 100644 --- a/plugins/adhoc/mod_adhoc.lua +++ b/plugins/adhoc/mod_adhoc.lua @@ -8,7 +8,7 @@ local it = require "util.iterators"; local st = require "util.stanza"; local is_admin = require "core.usermanager".is_admin; -local jid_split = require "util.jid".split; +local jid_host = require "util.jid".host; local adhoc_handle_cmd = module:require "adhoc".handle_cmd; local xmlns_cmd = "http://jabber.org/protocol/commands"; local commands = {}; @@ -21,12 +21,12 @@ module:hook("host-disco-info-node", function (event) local from = stanza.attr.from; local privileged = is_admin(from, stanza.attr.to); local global_admin = is_admin(from); - local username, hostname = jid_split(from); + local hostname = jid_host(from); local command = commands[node]; if (command.permission == "admin" and privileged) or (command.permission == "global_admin" and global_admin) or (command.permission == "local_user" and hostname == module.host) - or (command.permission == "user") then + or (command.permission == "any") then reply:tag("identity", { name = command.name, category = "automation", type = "command-node" }):up(); reply:tag("feature", { var = xmlns_cmd }):up(); @@ -52,12 +52,12 @@ module:hook("host-disco-items-node", function (event) local from = stanza.attr.from; local admin = is_admin(from, stanza.attr.to); local global_admin = is_admin(from); - local username, hostname = jid_split(from); + local hostname = jid_host(from); for node, command in it.sorted_pairs(commands) do if (command.permission == "admin" and admin) or (command.permission == "global_admin" and global_admin) or (command.permission == "local_user" and hostname == module.host) - or (command.permission == "user") then + or (command.permission == "any") then reply:tag("item", { name = command.name, node = node, jid = module:get_host() }); reply:up(); @@ -74,7 +74,7 @@ module:hook("iq-set/host/"..xmlns_cmd..":command", function (event) local from = stanza.attr.from; local admin = is_admin(from, stanza.attr.to); local global_admin = is_admin(from); - local username, hostname = jid_split(from); + local hostname = jid_host(from); if (command.permission == "admin" and not admin) or (command.permission == "global_admin" and not global_admin) or (command.permission == "local_user" and hostname ~= module.host) then diff --git a/plugins/mod_admin_adhoc.lua b/plugins/mod_admin_adhoc.lua index 37e77ab0..674b3339 100644 --- a/plugins/mod_admin_adhoc.lua +++ b/plugins/mod_admin_adhoc.lua @@ -59,7 +59,7 @@ local add_user_command_handler = adhoc_simple(add_user_layout, function(fields, if err then return generate_error_message(err); end - local username, host, resource = jid.split(fields.accountjid); + local username, host = jid.split(fields.accountjid); if module_host ~= host then return { status = "completed", error = { message = "Trying to add a user on " .. host .. " but command was sent to " .. module_host}}; end @@ -94,7 +94,7 @@ local change_user_password_command_handler = adhoc_simple(change_user_password_l if err then return generate_error_message(err); end - local username, host, resource = jid.split(fields.accountjid); + local username, host = jid.split(fields.accountjid); if module_host ~= host then return { status = "completed", @@ -136,7 +136,7 @@ local delete_user_command_handler = adhoc_simple(delete_user_layout, function(fi local failed = {}; local succeeded = {}; for _, aJID in ipairs(fields.accountjids) do - local username, host, resource = jid.split(aJID); + local username, host = jid.split(aJID); if (host == module_host) and usermanager_user_exists(username, host) and usermanager_delete_user(username, host) then module:log("debug", "User %s has been deleted", aJID); succeeded[#succeeded+1] = aJID; @@ -180,7 +180,7 @@ local end_user_session_handler = adhoc_simple(end_user_session_layout, function( local failed = {}; local succeeded = {}; for _, aJID in ipairs(fields.accountjids) do - local username, host, resource = jid.split(aJID); + local username, host = jid.split(aJID); if (host == module_host) and usermanager_user_exists(username, host) and disconnect_user(aJID) then succeeded[#succeeded+1] = aJID; else @@ -212,7 +212,7 @@ local get_user_password_handler = adhoc_simple(get_user_password_layout, functio if err then return generate_error_message(err); end - local user, host, resource = jid.split(fields.accountjid); + local user, host = jid.split(fields.accountjid); local accountjid; local password; if host ~= module_host then @@ -243,7 +243,7 @@ local get_user_roster_handler = adhoc_simple(get_user_roster_layout, function(fi return generate_error_message(err); end - local user, host, resource = jid.split(fields.accountjid); + local user, host = jid.split(fields.accountjid); if host ~= module_host then return { status = "completed", error = { message = "Tried to get roster for a user on " .. host .. " but command was sent to " .. module_host } }; elseif not usermanager_user_exists(user, host) then @@ -392,6 +392,12 @@ local function session_flags(session, line) if session.cert_identity_status == "valid" then flags[#flags+1] = "authenticated"; end + if session.dialback_key then + flags[#flags+1] = "dialback"; + end + if session.external_auth then + flags[#flags+1] = "SASL"; + end if session.secure then flags[#flags+1] = "encrypted"; end @@ -404,6 +410,12 @@ local function session_flags(session, line) if session.ip and session.ip:match(":") then flags[#flags+1] = "IPv6"; end + if session.incoming and session.outgoing then + flags[#flags+1] = "bidi"; + elseif session.is_bidi or session.bidi_session then + flags[#flags+1] = "bidi"; + end + line[#line+1] = "("..t_concat(flags, ", ")..")"; return t_concat(line, " "); diff --git a/plugins/mod_admin_telnet.lua b/plugins/mod_admin_telnet.lua index b0e349da..2efd424c 100644 --- a/plugins/mod_admin_telnet.lua +++ b/plugins/mod_admin_telnet.lua @@ -22,6 +22,7 @@ local prosody = _G.prosody; local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" }; +local unpack = table.unpack or unpack; -- luacheck: ignore 113 local iterators = require "util.iterators"; local keys, values = iterators.keys, iterators.values; local jid_bare, jid_split, jid_join = import("util.jid", "bare", "prepped_split", "join"); @@ -30,6 +31,9 @@ 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 serialize = require "util.serialization".new({ fatal = false, unquoted = true}); +local time = require "util.time"; local commands = module:shared("commands") local def_env = module:shared("env"); @@ -47,6 +51,24 @@ end console = {}; +local runner_callbacks = {}; + +function runner_callbacks:ready() + self.data.conn:resume(); +end + +function runner_callbacks:waiting() + self.data.conn:pause(); +end + +function runner_callbacks:error(err) + module:log("error", "Traceback[telnet]: %s", err); + + self.data.print("Fatal error while running command, it did not complete"); + self.data.print("Error: "..tostring(err)); +end + + function console:new_session(conn) local w = function(s) conn:write(s:gsub("\n", "\r\n")); end; local session = { conn = conn; @@ -62,6 +84,11 @@ function console:new_session(conn) }; session.env = setmetatable({}, default_env_mt); + session.thread = async.runner(function (line) + console:process_line(session, line); + session.send(string.char(0)); + end, runner_callbacks, session); + -- Load up environment with helper objects for name, t in pairs(def_env) do if type(t) == "table" then @@ -91,8 +118,14 @@ function console:process_line(session, line) session.env._ = line; + if not useglobalenv and commands[line:lower()] then + commands[line:lower()](session, line); + return; + end + local chunkname = "=console"; local env = (useglobalenv and redirect_output(_G, session)) or session.env or nil + -- luacheck: ignore 311/err local chunk, err = envload("return "..line, chunkname, env); if not chunk then chunk, err = envload(line, chunkname, env); @@ -105,18 +138,7 @@ function console:process_line(session, line) end end - local ranok, taskok, message = pcall(chunk); - - if not (ranok or message or useglobalenv) and commands[line:lower()] then - commands[line:lower()](session, line); - return; - end - - if not ranok then - session.print("Fatal error while running command, it did not complete"); - session.print("Error: "..taskok); - return; - end + local taskok, message = chunk(); if not message then session.print("Result: "..tostring(taskok)); @@ -150,8 +172,7 @@ function console_listener.onincoming(conn, data) for line in data:gmatch("[^\n]*[\n\004]") do if session.closed then return end - console:process_line(session, line); - session.send(string.char(0)); + session.thread:run(line); end session.partial_data = data:match("[^\n]+$"); end @@ -220,6 +241,7 @@ function commands.help(session, data) print [[server - Uptime, version, shutting down, etc.]] print [[port - Commands to manage ports the server is listening on]] print [[dns - Commands to manage and inspect the internal DNS resolver]] + print [[xmpp - Commands for sending XMPP stanzas]] print [[config - Reloading the configuration, etc.]] print [[console - Help regarding the console itself]] elseif section == "c2s" then @@ -227,7 +249,9 @@ function commands.help(session, data) print [[c2s:show_insecure() - Show all unencrypted client connections]] print [[c2s:show_secure() - Show all encrypted client connections]] print [[c2s:show_tls() - Show TLS cipher info for encrypted sessions]] + print [[c2s: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) - 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]] @@ -261,8 +285,11 @@ function commands.help(session, data) 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 == "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'.]] @@ -339,7 +366,7 @@ end def_env.module = {}; -local function get_hosts_set(hosts, module) +local function get_hosts_set(hosts) if type(hosts) == "table" then if hosts[1] then return set.new(hosts); @@ -349,17 +376,42 @@ local function get_hosts_set(hosts, module) elseif type(hosts) == "string" then return set.new { hosts }; elseif hosts == nil then - local hosts_set = set.new(array.collect(keys(prosody.hosts))) - / function (host) return (prosody.hosts[host].type == "local" or module and modulemanager.is_loaded(host, module)) and host or nil; end; - if module and modulemanager.get_module("*", module) then - hosts_set:add("*"); - end - return hosts_set; + return set.new(array.collect(keys(prosody.hosts))); + end +end + +-- Hosts with a module or all virtualhosts if no module given +-- matching modules_enabled in the global section +local function get_hosts_with_module(hosts, module) + local hosts_set = get_hosts_set(hosts) + / function (host) + if module then + -- Module given, filter in hosts with this module loaded + if modulemanager.is_loaded(host, module) then + return host; + else + return nil; + end + end + if not hosts then + -- No hosts given, filter in VirtualHosts + if prosody.hosts[host].type == "local" then + return host; + else + return nil + end + end; + -- No module given, but hosts are, don't filter at all + return host; + end; + if module and modulemanager.get_module("*", module) then + hosts_set:add("*"); end + return hosts_set; end function def_env.module:load(name, hosts, config) - hosts = get_hosts_set(hosts); + hosts = get_hosts_with_module(hosts); -- Load the module for each host local ok, err, count, mod = true, nil, 0; @@ -386,7 +438,7 @@ function def_env.module:load(name, hosts, config) end function def_env.module:unload(name, hosts) - hosts = get_hosts_set(hosts, name); + hosts = get_hosts_with_module(hosts, name); -- Unload the module for each host local ok, err, count = true, nil, 0; @@ -408,11 +460,11 @@ end local function _sort_hosts(a, b) if a == "*" then return true elseif b == "*" then return false - else return a < b; end + else return a:gsub("[^.]+", string.reverse):reverse() < b:gsub("[^.]+", string.reverse):reverse(); end end function def_env.module:reload(name, hosts) - hosts = array.collect(get_hosts_set(hosts, name)):sort(_sort_hosts) + hosts = array.collect(get_hosts_with_module(hosts, name)):sort(_sort_hosts) -- Reload the module for each host local ok, err, count = true, nil, 0; @@ -435,16 +487,7 @@ function def_env.module:reload(name, hosts) end function def_env.module:list(hosts) - if hosts == nil then - hosts = array.collect(keys(prosody.hosts)); - table.insert(hosts, 1, "*"); - end - if type(hosts) == "string" then - hosts = { hosts }; - end - if type(hosts) ~= "table" then - return false, "Please supply a host or a list of hosts you would like to see"; - end + hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts); local print = self.session.print; for _, host in ipairs(hosts) do @@ -458,7 +501,12 @@ function def_env.module:list(hosts) end else for _, name in ipairs(modules) do - print(" "..name); + local status, status_text = modulemanager.get_module(host, name).module:get_status(); + local status_summary = ""; + if status == "warn" or status == "error" then + status_summary = (" (%s: %s)"):format(status, status_text); + end + print((" %s%s"):format(name, status_summary)); end end end @@ -474,9 +522,12 @@ function def_env.config:load(filename, format) return true, "Config loaded"; end -function def_env.config:get(host, section, key) +function def_env.config:get(host, key) + if key == nil then + host, key = "*", host; + end local config_get = require "core.configmanager".get - return true, tostring(config_get(host, section, key)); + return true, serialize(config_get(host, key)); end function def_env.config:reload() @@ -505,6 +556,12 @@ local function session_flags(session, line) if session.cert_identity_status == "valid" then line[#line+1] = "(authenticated)"; end + if session.dialback_key then + line[#line+1] = "(dialback)"; + end + if session.external_auth then + line[#line+1] = "(SASL)"; + end if session.secure then line[#line+1] = "(encrypted)"; end @@ -520,6 +577,17 @@ local function session_flags(session, line) if session.remote then line[#line+1] = "(remote)"; end + if session.incoming and session.outgoing then + line[#line+1] = "(bidi)"; + elseif session.is_bidi or session.bidi_session then + line[#line+1] = "(bidi)"; + end + if session.bosh_version then + line[#line+1] = "(bosh)"; + end + if session.websocket_request then + line[#line+1] = "(websocket)"; + end return table.concat(line, " "); end @@ -536,6 +604,18 @@ local function tls_info(session, line) -- TLS session might not be ready yet line[#line+1] = "(cipher info unavailable)"; end + if sock.getsniname then + local name = sock:getsniname(); + if name then + line[#line+1] = ("(SNI:%q)"):format(name); + end + end + if sock.getalpn then + local proto = sock:getalpn(); + if proto then + line[#line+1] = ("(ALPN:%q)"):format(proto); + end + end end else line[#line+1] = "(insecure)"; @@ -558,23 +638,31 @@ local function get_jid(session) return jid_join("["..ip.."]:"..clientport, session.host or "["..serverip.."]:"..serverport); end +local function get_c2s() + local c2s = array.collect(values(prosody.full_sessions)); + c2s:append(array.collect(values(module:shared"/*/c2s/sessions"))); + c2s:append(array.collect(values(module:shared"/*/bosh/sessions"))); + c2s:unique(); + return c2s; +end + local function show_c2s(callback) - local c2s = array.collect(values(module:shared"/*/c2s/sessions")); - c2s:sort(function(a, b) + get_c2s():sort(function(a, b) if a.host == b.host then if a.username == b.username then return (a.resource or "") > (b.resource or ""); end return (a.username or "") > (b.username or ""); end - return (a.host or "") > (b.host or ""); + return _sort_hosts(a.host or "", b.host or ""); end):map(function (session) callback(get_jid(session), session) end); end function def_env.c2s:count() - return true, "Total: ".. iterators.count(values(module:shared"/*/c2s/sessions")) .." clients"; + local c2s = get_c2s(); + return true, "Total: ".. #c2s .." clients"; end function def_env.c2s:show(match_jid, annotate) @@ -620,17 +708,36 @@ function def_env.c2s:show_tls(match_jid) return self:show(match_jid, tls_info); end -function def_env.c2s:close(match_jid) +local function build_reason(text, condition) + if text or condition then + return { + text = text, + condition = condition or "undefined-condition", + }; + end +end + +function def_env.c2s:close(match_jid, text, condition) local count = 0; show_c2s(function (jid, session) if jid == match_jid or jid_bare(jid) == match_jid then count = count + 1; - session:close(); + session:close(build_reason(text, condition)); end end); return true, "Total: "..count.." sessions closed"; end +function def_env.c2s:closeall(text, condition) + local count = 0; + --luacheck: ignore 212/jid + show_c2s(function (jid, session) + count = count + 1; + session:close(build_reason(text, condition)); + end); + return true, "Total: "..count.." sessions closed"; +end + def_env.s2s = {}; function def_env.s2s:show(match_jid, annotate) @@ -698,8 +805,8 @@ function def_env.s2s:show(match_jid, annotate) -- Sort by local host, then remote host table.sort(s2s_list, function(a,b) - if a.l == b.l then return a.r < b.r; end - return a.l < b.l; + if a.l == b.l then return _sort_hosts(a.r, b.r); end + return _sort_hosts(a.l, b.l); end); local lasthost; for _, sess_lines in ipairs(s2s_list) do @@ -831,7 +938,7 @@ function def_env.s2s:showcert(domain) .." presented by "..domain.."."); end -function def_env.s2s:close(from, to) +function def_env.s2s:close(from, to, text, condition) local print, count = self.session.print, 0; local s2s_sessions = module:shared"/*/s2s/sessions"; @@ -845,23 +952,23 @@ function def_env.s2s:close(from, to) end for _, session in pairs(s2s_sessions) do - local id = session.type..tostring(session):match("[a-f0-9]+$"); + local id = session.id or (session.type..tostring(session):match("[a-f0-9]+$")); if (match_id and match_id == id) or (session.from_host == from and session.to_host == to) then print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id)); - (session.close or s2smanager.destroy_session)(session); + (session.close or s2smanager.destroy_session)(session, build_reason(text, condition)); count = count + 1 ; end end return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end -function def_env.s2s:closeall(host) +function def_env.s2s:closeall(host, text, condition) local count = 0; local s2s_sessions = module:shared"/*/s2s/sessions"; for _,session in pairs(s2s_sessions) do if not host or session.from_host == host or session.to_host == host then - session:close(); + session:close(build_reason(text, condition)); count = count + 1; end end @@ -882,7 +989,7 @@ function def_env.host:list() local print = self.session.print; local i = 0; local type; - for host, host_session in iterators.sorted_pairs(prosody.hosts) do + for host, host_session in iterators.sorted_pairs(prosody.hosts, _sort_hosts) do i = i + 1; type = host_session.type; if type == "local" then @@ -1065,13 +1172,28 @@ end def_env.xmpp = {}; local st = require "util.stanza"; -function def_env.xmpp:ping(localhost, remotehost) - if prosody.hosts[localhost] then - module:send(st.iq{ from=localhost, to=remotehost, type="get", id="ping" } - :tag("ping", {xmlns="urn:xmpp:ping"}), prosody.hosts[localhost]); - return true, "Sent ping"; +local new_id = require "util.id".medium; +function def_env.xmpp:ping(localhost, remotehost, timeout) + localhost = select(2, jid_split(localhost)); + remotehost = select(2, jid_split(remotehost)); + if not localhost then + return nil, "Invalid sender hostname"; + elseif not prosody.hosts[localhost] then + return nil, "No such local host"; + end + if not remotehost then + return nil, "Invalid destination hostname"; + elseif prosody.hosts[remotehost] then + return nil, "Both hosts are local"; + end + local iq = st.iq{ from=localhost, to=remotehost, type="get", id=new_id()} + :tag("ping", {xmlns="urn:xmpp:ping"}); + local time_start = time.now(); + local ret, err = async.wait(module:context(localhost):send_iq(iq, nil, timeout)); + if ret then + return true, ("pong from %s in %gs"):format(ret.stanza.attr.from, time.now() - time_start); else - return nil, "No such host"; + return false, tostring(err); end end @@ -1089,14 +1211,12 @@ end function def_env.dns:lookup(name, typ, class) local resolver = get_resolver(self.session); - local ret = "Query sent"; - local print = self.session.print; - local function handler(...) - ret = "Got response"; - print(...); + local ret, err = async.wait(resolver:lookup_promise(name, typ, class)); + if ret then + return true, ret; + elseif err then + return false, err; end - resolver:lookup(handler, name, typ, class); - return true, ret; end function def_env.dns:addnameserver(...) @@ -1124,10 +1244,10 @@ end def_env.http = {}; -function def_env.http:list() +function def_env.http:list(hosts) local print = self.session.print; - for host in pairs(prosody.hosts) do + for host in get_hosts_set(hosts) do local http_apps = modulemanager.get_items("http-provider", host); if #http_apps > 0 then local http_host = module:context(host):get_option_string("http_host"); @@ -1173,7 +1293,6 @@ function def_env.debug:events(host, event) end function def_env.debug:timers() - local socket = require "socket"; local print = self.session.print; local add_task = require"util.timer".add_task; local h, params = add_task.h, add_task.params; @@ -1201,7 +1320,7 @@ function def_env.debug:timers() if h then local next_time = h:peek(); if next_time then - return true, os.date("Next event at %F %T (in %%.6fs)", next_time):format(next_time - socket.gettime()); + return true, os.date("Next event at %F %T (in %%.6fs)", next_time):format(next_time - time.now()); end end return true; @@ -1223,7 +1342,7 @@ local function format_stat(type, value, ref_value) --do return tostring(value) end if type == "duration" then if ref_value < 0.001 then - return ("%d µs"):format(value*1000000); + return ("%g µs"):format(value*1000000); elseif ref_value < 0.9 then return ("%0.2f ms"):format(value*1000); end @@ -1342,7 +1461,7 @@ end function stats_methods:cfgraph() for _, stat_info in ipairs(self) do - local name, type, value, data = unpack(stat_info, 1, 4); + local name, type, value, data = unpack(stat_info, 1, 4); -- luacheck: ignore 211 local function print(s) table.insert(stat_info.output, s); end @@ -1408,7 +1527,7 @@ end function stats_methods:histogram() for _, stat_info in ipairs(self) do - local name, type, value, data = unpack(stat_info, 1, 4); + local name, type, value, data = unpack(stat_info, 1, 4); -- luacheck: ignore 211 local function print(s) table.insert(stat_info.output, s); end @@ -1508,10 +1627,11 @@ local function new_stats_context(self) end function def_env.stats:show(filter) + -- luacheck: ignore 211/changed local stats, changed, extra = require "core.statsmanager".get_stats(); local available, displayed = 0, 0; local displayed_stats = new_stats_context(self); - for name, value in pairs(stats) do + for name, value in iterators.sorted_pairs(stats) do available = available + 1; if not filter or name:match(filter) then displayed = displayed + 1; diff --git a/plugins/mod_announce.lua b/plugins/mod_announce.lua index 970a273a..c742ebb8 100644 --- a/plugins/mod_announce.lua +++ b/plugins/mod_announce.lua @@ -38,6 +38,7 @@ end -- Old <message>-based jabberd-style announcement sending function handle_announcement(event) local stanza = event.stanza; + -- luacheck: ignore 211/node local node, host, resource = jid.split(stanza.attr.to); if resource ~= "announce/online" then diff --git a/plugins/mod_auth_internal_hashed.lua b/plugins/mod_auth_internal_hashed.lua index 083f648b..c81830de 100644 --- a/plugins/mod_auth_internal_hashed.lua +++ b/plugins/mod_auth_internal_hashed.lua @@ -9,7 +9,7 @@ local max = math.max; -local getAuthenticationDatabaseSHA1 = require "util.sasl.scram".getAuthenticationDatabaseSHA1; +local scram_hashers = require "util.sasl.scram".hashers; local usermanager = require "core.usermanager"; local generate_uuid = require "util.uuid".generate; local new_sasl = require "util.sasl".new; @@ -21,7 +21,9 @@ local host = module.host; local accounts = module:open_store("accounts"); - +local hash_name = module:get_option_string("password_hash", "SHA-1"); +local get_auth_db = assert(scram_hashers[hash_name], "SCRAM-"..hash_name.." not supported by SASL library"); +local scram_name = "scram_"..hash_name:gsub("%-","_"):lower(); -- Default; can be set per-user local default_iteration_count = 4096; @@ -49,7 +51,7 @@ function provider.test_password(username, password) return nil, "Auth failed. Stored salt and iteration count information is not complete."; end - local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, credentials.salt, credentials.iteration_count); + local valid, stored_key, server_key = get_auth_db(password, credentials.salt, credentials.iteration_count); local stored_key_hex = to_hex(stored_key); local server_key_hex = to_hex(server_key); @@ -67,7 +69,7 @@ function provider.set_password(username, password) if account then account.salt = generate_uuid(); account.iteration_count = max(account.iteration_count or 0, default_iteration_count); - local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, account.salt, account.iteration_count); + local valid, stored_key, server_key = get_auth_db(password, account.salt, account.iteration_count); if not valid then return valid, stored_key; end @@ -101,7 +103,7 @@ function provider.create_user(username, password) return accounts:set(username, {}); end local salt = generate_uuid(); - local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, salt, default_iteration_count); + local valid, stored_key, server_key = get_auth_db(password, salt, default_iteration_count); if not valid then return valid, stored_key; end @@ -122,7 +124,7 @@ function provider.get_sasl_handler() plain_test = function(_, username, password, realm) return usermanager.test_password(username, realm, password), true; end, - scram_sha_1 = function(_, username) + [scram_name] = function(_, username) local credentials = accounts:get(username); if not credentials then return; end if credentials.password then diff --git a/plugins/mod_authz_internal.lua b/plugins/mod_authz_internal.lua new file mode 100644 index 00000000..0f6e4873 --- /dev/null +++ b/plugins/mod_authz_internal.lua @@ -0,0 +1,22 @@ +local normalize = require "util.jid".prep; +local admin_jids = module:get_option_inherited_set("admins", {}) / normalize; +local host = module.host; +local role_store = module:open_store("roles"); + +local admin_role = { ["prosody:admin"] = true }; + +function get_user_roles(user) + if admin_jids:contains(user.."@"..host) then + return admin_role; + end + return role_store:get(user); +end + +function get_jid_roles(jid) + if admin_jids:contains(jid) then + return admin_role; + end + return nil; +end + + diff --git a/plugins/mod_blocklist.lua b/plugins/mod_blocklist.lua index ee48ffad..dad06b62 100644 --- a/plugins/mod_blocklist.lua +++ b/plugins/mod_blocklist.lua @@ -67,7 +67,7 @@ local function migrate_privacy_list(username) if item.type == "jid" and item.action == "deny" then local jid = jid_prep(item.value); if not jid then - module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, tostring(item.value)); + module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, item.value); else migrated_data[jid] = true; end @@ -162,7 +162,7 @@ local function edit_blocklist(event) local blocklist = cache[username] or get_blocklist(username); local new_blocklist = { - -- We set the [false] key to someting as a signal not to migrate privacy lists + -- We set the [false] key to something as a signal not to migrate privacy lists [false] = blocklist[false] or { created = now; }; }; if type(blocklist[false]) == "table" then @@ -189,6 +189,7 @@ local function edit_blocklist(event) if is_blocking then for jid in pairs(send_unavailable) do + -- Check that this JID isn't already blocked, i.e. this is not a change if not blocklist[jid] then for _, session in pairs(sessions[username].sessions) do if session.presence then diff --git a/plugins/mod_bosh.lua b/plugins/mod_bosh.lua index d4701148..d4c1ac6a 100644 --- a/plugins/mod_bosh.lua +++ b/plugins/mod_bosh.lua @@ -44,19 +44,41 @@ local bosh_max_polling = module:get_option_number("bosh_max_polling", 5); local bosh_max_wait = module:get_option_number("bosh_max_wait", 120); local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure"); -local cross_domain = module:get_option("cross_domain_bosh", false); +local cross_domain = module:get_option("cross_domain_bosh"); -if cross_domain == true then cross_domain = "*"; end -if type(cross_domain) == "table" then cross_domain = table.concat(cross_domain, ", "); end +if cross_domain ~= nil then + module:log("info", "The 'cross_domain_bosh' option has been deprecated"); +end local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat; -- All sessions, and sessions that have no requests open local sessions = module:shared("sessions"); +local measure_active = module:measure("active_sessions", "amount"); +local measure_inactive = module:measure("inactive_sessions", "amount"); +local report_bad_host = module:measure("bad_host", "rate"); +local report_bad_sid = module:measure("bad_sid", "rate"); +local report_new_sid = module:measure("new_sid", "rate"); +local report_timeout = module:measure("timeout", "rate"); + +module:hook("stats-update", function () + local active = 0; + local inactive = 0; + for _, session in pairs(sessions) do + if #session.requests > 0 then + active = active + 1; + else + inactive = inactive + 1; + end + end + measure_active(active); + measure_inactive(inactive); +end); + -- Used to respond to idle sessions (those with waiting requests) function on_destroy_request(request) - log("debug", "Request destroyed: %s", tostring(request)); + log("debug", "Request destroyed: %s", request); local session = sessions[request.context.sid]; if session then local requests = session.requests; @@ -73,7 +95,7 @@ function on_destroy_request(request) if session.inactive_timer then session.inactive_timer:stop(); end - session.inactive_timer = module:add_timer(max_inactive, check_inactive, session, request.context, + session.inactive_timer = module:add_timer(max_inactive, session_timeout, session, request.context, "BOSH client silent for over "..max_inactive.." seconds"); (session.log or log)("debug", "BOSH session marked as inactive (for %ds)", max_inactive); end @@ -84,31 +106,16 @@ function on_destroy_request(request) end end -function check_inactive(now, session, context, reason) -- luacheck: ignore 212/now +function session_timeout(now, session, context, reason) -- luacheck: ignore 212/now if not session.destroyed then + report_timeout(); sessions[context.sid] = nil; sm_destroy_session(session, reason); end end -local function set_cross_domain_headers(response) - local headers = response.headers; - headers.access_control_allow_methods = "GET, POST, OPTIONS"; - headers.access_control_allow_headers = "Content-Type"; - headers.access_control_max_age = "7200"; - headers.access_control_allow_origin = cross_domain; - return response; -end - -function handle_OPTIONS(event) - if cross_domain and event.request.headers.origin then - set_cross_domain_headers(event.response); - end - return ""; -end - function handle_POST(event) - log("debug", "Handling new request %s: %s\n----------", tostring(event.request), tostring(event.request.body)); + log("debug", "Handling new request %s: %s\n----------", event.request, event.request.body); local request, response = event.request, event.response; response.on_destroy = on_destroy_request; @@ -121,10 +128,6 @@ function handle_POST(event) local headers = response.headers; headers.content_type = "text/xml; charset=utf-8"; - if cross_domain and request.headers.origin then - set_cross_domain_headers(response); - end - -- stream:feed() calls the stream_callbacks, so all stanzas in -- the body are processed in this next line before it returns. -- In particular, the streamopened() stream callback is where @@ -205,6 +208,7 @@ function handle_POST(event) return; end module:log("warn", "Unable to associate request with a session (incomplete request?)"); + report_bad_sid(); local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", ["xmlns:stream"] = xmlns_streams, condition = "item-not-found" }); return tostring(close_reply) .. "\n"; @@ -220,7 +224,7 @@ local function bosh_reset_stream(session) session.notopen = true; end local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" }; local function bosh_close_stream(session, reason) - (session.log or log)("info", "BOSH client disconnected: %s", tostring((reason and reason.condition or reason) or "session close")); + (session.log or log)("info", "BOSH client disconnected: %s", (reason and reason.condition or reason) or "session close"); local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", ["xmlns:stream"] = xmlns_streams }); @@ -245,7 +249,7 @@ local function bosh_close_stream(session, reason) close_reply = reason; end end - log("info", "Disconnecting client, <stream:error> is: %s", tostring(close_reply)); + log("info", "Disconnecting client, <stream:error> is: %s", close_reply); end local response_body = tostring(close_reply); @@ -268,17 +272,27 @@ function stream_callbacks.streamopened(context, attr) -- New session request context.notopen = nil; -- Signals that we accept this opening tag + if not attr.to then + log("debug", "BOSH client tried to connect without specifying a host"); + report_bad_host(); + local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", + ["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" }); + response:send(tostring(close_reply)); + return; + end + local to_host = nameprep(attr.to); local wait = tonumber(attr.wait); if not to_host then - log("debug", "BOSH client tried to connect to invalid host: %s", tostring(attr.to)); + log("debug", "BOSH client tried to connect to invalid host: %s", attr.to); + report_bad_host(); local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", ["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" }); response:send(tostring(close_reply)); return; end if not rid or (not attr.wait or not wait or wait < 0 or wait % 1 ~= 0) then - log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", tostring(attr.rid), tostring(attr.wait)); + log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", attr.rid, attr.wait); local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", ["xmlns:stream"] = xmlns_streams, condition = "bad-request" }); response:send(tostring(close_reply)); @@ -309,6 +323,7 @@ function stream_callbacks.streamopened(context, attr) session.log("debug", "BOSH session created for request from %s", session.ip); log("info", "New BOSH session, assigned it sid '%s'", sid); + report_new_sid(); module:fire_event("bosh-session", { session = session, request = request }); @@ -323,7 +338,7 @@ function stream_callbacks.streamopened(context, attr) s.attr.xmlns = "jabber:client"; end s = filter("stanzas/out", s); - --log("debug", "Sending BOSH data: %s", tostring(s)); + --log("debug", "Sending BOSH data: %s", s); if not s then return true end t_insert(session.send_buffer, tostring(s)); @@ -363,6 +378,7 @@ function stream_callbacks.streamopened(context, attr) if not session then -- Unknown sid log("info", "Client tried to use sid '%s' which we don't know about", sid); + report_bad_sid(); response:send(tostring(st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", condition = "item-not-found" }))); context.notopen = nil; return; @@ -425,7 +441,7 @@ function stream_callbacks.streamopened(context, attr) end end -local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(tostring(err), 2)); end +local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(err, 2)); end function runner_callbacks:error(err) -- luacheck: ignore 212/self return handleerr(err); @@ -495,14 +511,16 @@ function stream_callbacks.error(context, error) end end +local GET_response_body = [[<html><body> + <p>It works! Now point your BOSH client to this URL to connect to Prosody.</p> + <p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p> + </body></html>]]; + local GET_response = { headers = { content_type = "text/html"; }; - body = [[<html><body> - <p>It works! Now point your BOSH client to this URL to connect to Prosody.</p> - <p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p> - </body></html>]]; + body = module:get_option_string("bosh_get_response_body", GET_response_body); }; module:depends("http"); @@ -511,8 +529,6 @@ module:provides("http", { route = { ["GET"] = GET_response; ["GET /"] = GET_response; - ["OPTIONS"] = handle_OPTIONS; - ["OPTIONS /"] = handle_OPTIONS; ["POST"] = handle_POST; ["POST /"] = handle_POST; }; diff --git a/plugins/mod_c2s.lua b/plugins/mod_c2s.lua index 15d3a9be..536b945e 100644 --- a/plugins/mod_c2s.lua +++ b/plugins/mod_c2s.lua @@ -56,6 +56,11 @@ local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'}; function stream_callbacks.streamopened(session, attr) local send = session.send; + if not attr.to then + session:close{ condition = "improper-addressing", + text = "A 'to' attribute is required on stream headers" }; + return; + end local host = nameprep(attr.to); if not host then session:close{ condition = "improper-addressing", @@ -97,7 +102,6 @@ function stream_callbacks.streamopened(session, attr) session.compressed = info.compression; else (session.log or log)("info", "Stream encrypted"); - session.compressed = sock.compression and sock:compression(); --COMPAT mw/luasec-hg end end @@ -106,7 +110,13 @@ function stream_callbacks.streamopened(session, attr) if features.tags[1] or session.full_jid then send(features); else - (session.log or log)("warn", "No stream features to offer"); + if session.secure then + -- Here SASL should be offered + (session.log or log)("warn", "No stream features to offer on secure session. Check authentication settings."); + else + -- Normally STARTTLS would be offered + (session.log or log)("warn", "No stream features to offer on insecure session. Check encryption and security settings."); + end session:close{ condition = "undefined-condition", text = "No stream features to proceed with" }; end end @@ -121,7 +131,7 @@ function stream_callbacks.error(session, error, data) session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}"))); session:close("invalid-namespace"); elseif error == "parse-error" then - (session.log or log)("debug", "Client XML parse error: %s", tostring(data)); + (session.log or log)("debug", "Client XML parse error: %s", data); session:close("not-well-formed"); elseif error == "stream-error" then local condition, text = "undefined-condition"; @@ -251,8 +261,6 @@ function listener.onconnect(conn) local sock = conn:socket(); if sock.info then session.compressed = sock:info"compression"; - elseif sock.compression then - session.compressed = sock:compression(); --COMPAT mw/luasec-hg end end @@ -283,7 +291,7 @@ function listener.onconnect(conn) if data then local ok, err = stream:feed(data); if not ok then - log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_")); + log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300)); session:close("not-well-formed"); end end @@ -327,6 +335,13 @@ function listener.onreadtimeout(conn) end end +function listener.ondrain(conn) + local session = sessions[conn]; + if session then + return (hosts[session.host] or prosody).events.fire_event("c2s-ondrain", { session = session }); + end +end + local function keepalive(event) local session = event.session; if not session.notopen then @@ -359,6 +374,7 @@ module:provides("net", { default_port = 5222; encryption = "starttls"; multiplex = { + protocol = "xmpp-client"; pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:client%1.*>"; }; }); diff --git a/plugins/mod_carbons.lua b/plugins/mod_carbons.lua index 79d3e737..c5efce39 100644 --- a/plugins/mod_carbons.lua +++ b/plugins/mod_carbons.lua @@ -20,6 +20,42 @@ end module:hook("iq-set/self/"..xmlns_carbons..":disable", toggle_carbons); module:hook("iq-set/self/"..xmlns_carbons..":enable", toggle_carbons); +local function should_copy(stanza, c2s, user_bare) + local st_type = stanza.attr.type or "normal"; + if stanza:get_child("private", xmlns_carbons) then + return false, "private"; + end + + if stanza:get_child("no-copy", "urn:xmpp:hints") then + return false, "hint"; + end + + if not c2s and stanza.attr.to ~= user_bare and stanza:get_child("x", "http://jabber.org/protocol/muc#user") then + -- MUC PMs are normally sent to full JIDs + return false, "muc-pm"; + end + + if st_type == "chat" then + return true, "type"; + end + + if st_type == "normal" and stanza:get_child("body") then + return true, "type"; + end + + if st_type == "error" and not c2s and not (stanza.attr.from or ""):find("/") then + return true, "bounce"; + end + + for archived in stanza:childtags("stanza-id", "urn:xmpp:sid:0") do + if archived and archived.attr.by == user_bare then + return true, "archived"; + end + end + + return false, "default"; +end + local function message_handler(event, c2s) local origin, stanza = event.origin, event.stanza; local orig_type = stanza.attr.type or "normal"; @@ -28,10 +64,6 @@ local function message_handler(event, c2s) local orig_to = stanza.attr.to; local bare_to = jid_bare(orig_to); - if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body"))) then - return -- Only chat type messages - end - -- Stanza sent by a local client local bare_jid = bare_from; -- JID of the local user local target_session = origin; @@ -56,35 +88,19 @@ local function message_handler(event, c2s) return -- No use in sending carbons to an offline user end - if stanza:get_child("private", xmlns_carbons) then - if not c2s then + local should, why = should_copy(stanza, c2s, bare_jid); + + if not should then + module:log("debug", "Not copying stanza: %s (%s)", stanza:top_tag(), why); + elseif why == "private" and not c2s then stanza:maptags(function(tag) if not ( tag.attr.xmlns == xmlns_carbons and tag.name == "private" ) then return tag; end end); - end - module:log("debug", "Message tagged private, ignoring"); - return - elseif stanza:get_child("no-copy", "urn:xmpp:hints") then - module:log("debug", "Message has no-copy hint, ignoring"); - return - elseif not c2s and bare_jid ~= orig_to and stanza:get_child("x", "http://jabber.org/protocol/muc#user") then - module:log("debug", "MUC PM, ignoring"); - return - end - - -- Create the carbon copy and wrap it as per the Stanza Forwarding XEP - local copy = st.clone(stanza); - if c2s and not orig_to then - stanza.attr.to = bare_from; end - copy.attr.xmlns = "jabber:client"; - local carbon = st.message{ from = bare_jid, type = orig_type, } - :tag(c2s and "sent" or "received", { xmlns = xmlns_carbons }) - :tag("forwarded", { xmlns = xmlns_forward }) - :add_child(copy):reset(); + local carbon; user_sessions = user_sessions and user_sessions.sessions; for _, session in pairs(user_sessions) do -- Carbons are sent to resources that have enabled it @@ -93,6 +109,20 @@ local function message_handler(event, c2s) and session ~= target_session -- and isn't among the top resources that would receive the message per standard routing rules and (c2s or session.priority ~= top_priority) then + if not carbon then + -- Create the carbon copy and wrap it as per the Stanza Forwarding XEP + local copy = st.clone(stanza); + if c2s and not orig_to then + stanza.attr.to = bare_from; + end + copy.attr.xmlns = "jabber:client"; + carbon = st.message{ from = bare_jid, type = orig_type, } + :tag(c2s and "sent" or "received", { xmlns = xmlns_carbons }) + :tag("forwarded", { xmlns = xmlns_forward }) + :add_child(copy):reset(); + + end + carbon.attr.to = session.full_jid; module:log("debug", "Sending carbon to %s", session.full_jid); session.send(carbon); diff --git a/plugins/mod_component.lua b/plugins/mod_component.lua index b41204a2..9ffc496e 100644 --- a/plugins/mod_component.lua +++ b/plugins/mod_component.lua @@ -49,6 +49,7 @@ function module.add_host(module) local send; local function on_destroy(session, err) --luacheck: ignore 212/err + module:set_status("warn", err and ("Disconnected: "..err) or "Disconnected"); env.connected = false; env.session = false; send = nil; @@ -102,6 +103,7 @@ function module.add_host(module) module:log("info", "External component successfully authenticated"); session.send(st.stanza("handshake")); module:fire_event("component-authenticated", { session = session }); + module:set_status("info", "Connected"); return true; end @@ -130,7 +132,7 @@ function module.add_host(module) end module:log("warn", "Component not connected, bouncing error for: %s", stanza:top_tag()); if stanza.attr.type ~= "error" and stanza.attr.type ~= "result" then - event.origin.send(st.error_reply(stanza, "wait", "service-unavailable", "Component unavailable")); + event.origin.send(st.error_reply(stanza, "wait", "service-unavailable", "Component unavailable", module.host)); end end return true; @@ -165,11 +167,11 @@ local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; function stream_callbacks.error(session, error, data) if session.destroyed then return; end - module:log("warn", "Error processing component stream: %s", tostring(error)); + module:log("warn", "Error processing component stream: %s", error); if error == "no-stream" then session:close("invalid-namespace"); elseif error == "parse-error" then - session.log("warn", "External component %s XML parse error: %s", tostring(session.host), tostring(data)); + session.log("warn", "External component %s XML parse error: %s", session.host, data); session:close("not-well-formed"); elseif error == "stream-error" then local condition, text = "undefined-condition"; @@ -206,7 +208,7 @@ function stream_callbacks.streamclosed(session) session:close(); end -local function handleerr(err) log("error", "Traceback[component]: %s", traceback(tostring(err), 2)); end +local function handleerr(err) log("error", "Traceback[component]: %s", traceback(err, 2)); end function stream_callbacks.handlestanza(session, stanza) -- Namespaces are icky. if not stanza.attr.xmlns and stanza.name == "handshake" then @@ -266,10 +268,10 @@ local function session_close(session, reason) if reason.extra then stanza:add_child(reason.extra); end - module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(stanza)); + module:log("info", "Disconnecting component, <stream:error> is: %s", stanza); session.send(stanza); elseif reason.name then -- a stanza - module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(reason)); + module:log("info", "Disconnecting component, <stream:error> is: %s", reason); session.send(reason); end end @@ -310,7 +312,7 @@ function listener.onconnect(conn) function session.data(_, data) local ok, err = stream:feed(data); if ok then return; end - module:log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_")); + log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300)); session:close("not-well-formed"); end @@ -325,7 +327,7 @@ end function listener.ondisconnect(conn, err) local session = sessions[conn]; if session then - (session.log or log)("info", "component disconnected: %s (%s)", tostring(session.host), tostring(err)); + (session.log or log)("info", "component disconnected: %s (%s)", session.host, err); if session.host then module:context(session.host):fire_event("component-disconnected", { session = session, reason = err }); end diff --git a/plugins/mod_csi.lua b/plugins/mod_csi.lua index 84476cac..458ff491 100644 --- a/plugins/mod_csi.lua +++ b/plugins/mod_csi.lua @@ -2,8 +2,9 @@ local st = require "util.stanza"; local xmlns_csi = "urn:xmpp:csi:0"; local csi_feature = st.stanza("csi", { xmlns = xmlns_csi }); +local csi_handler_available = nil; module:hook("stream-features", function (event) - if event.origin.username then + if event.origin.username and csi_handler_available then event.features:add_child(csi_feature); end end); @@ -21,3 +22,14 @@ end module:hook("stanza/"..xmlns_csi..":active", refire_event("csi-client-active")); module:hook("stanza/"..xmlns_csi..":inactive", refire_event("csi-client-inactive")); +function module.load() + if prosody.hosts[module.host].events._handlers["csi-client-active"] then + csi_handler_available = true; + module:set_status("core", "CSI handler module loaded"); + else + csi_handler_available = false; + module:set_status("warn", "No CSI handler module loaded"); + end +end +module:hook("module-loaded", module.load); +module:hook("module-unloaded", module.load); diff --git a/plugins/mod_csi_simple.lua b/plugins/mod_csi_simple.lua index da2dd953..d3bf3a9f 100644 --- a/plugins/mod_csi_simple.lua +++ b/plugins/mod_csi_simple.lua @@ -1,4 +1,4 @@ --- Copyright (C) 2016-2018 Kim Alvefur +-- Copyright (C) 2016-2020 Kim Alvefur -- -- This project is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. @@ -9,51 +9,28 @@ module:depends"csi" local jid = require "util.jid"; local st = require "util.stanza"; local dt = require "util.datetime"; -local new_queue = require "util.queue".new; - -local function new_pump(output, ...) - -- luacheck: ignore 212/self - local q = new_queue(...); - local flush = true; - function q:pause() - flush = false; - end - function q:resume() - flush = true; - return q:flush(); - end - local push = q.push; - function q:push(item) - local ok = push(self, item); - if not ok then - q:flush(); - output(item, self); - elseif flush then - return q:flush(); - end - return true; - end - function q:flush() - local item = self:pop(); - while item do - output(item, self); - item = self:pop(); - end - return true; - end - return q; -end +local filters = require "util.filters"; local queue_size = module:get_option_number("csi_queue_size", 256); +local important_payloads = module:get_option_set("csi_important_payloads", { }); + module:hook("csi-is-stanza-important", function (event) local stanza = event.stanza; if not st.is_stanza(stanza) then + -- whitespace pings etc + return true; + end + if stanza.attr.xmlns ~= nil then + -- stream errors, stream management etc return true; end local st_name = stanza.name; if not st_name then return false; end local st_type = stanza.attr.type; + if st_type == "error" then + return true; + end if st_name == "presence" then if st_type == nil or st_type == "unavailable" then return false; @@ -79,42 +56,112 @@ module:hook("csi-is-stanza-important", function (event) if stanza:get_child("encryption", "urn:xmpp:eme:0") then return true; end + if stanza:get_child("x", "jabber:x:conference") or stanza:find("{http://jabber.org/protocol/muc#user}x/invite") then + return true; + end + for important in important_payloads do + if stanza:find(important) then + return true; + end + end return false; + elseif st_name == "iq" then + return true; end - return true; end, -1); -module:hook("csi-client-inactive", function (event) - local session = event.origin; - if session.pump then - session.pump:pause(); +local function with_timestamp(stanza, from) + if st.is_stanza(stanza) and stanza.attr.xmlns == nil and stanza.name ~= "iq" then + stanza = st.clone(stanza); + stanza:add_direct_child(st.stanza("delay", {xmlns = "urn:xmpp:delay", from = from, stamp = dt.datetime()})); + end + return stanza; +end + +local function manage_buffer(stanza, session) + local ctr = session.csi_counter or 0; + if ctr >= queue_size then + session.log("debug", "Queue size limit hit, flushing buffer (queue size is %d)", session.csi_counter); + session.conn:resume_writes(); + elseif module:fire_event("csi-is-stanza-important", { stanza = stanza, session = session }) then + session.log("debug", "Important stanza, flushing buffer (queue size is %d)", session.csi_counter); + session.conn:resume_writes(); else - local bare_jid = jid.join(session.username, session.host); - local send = session.send; - session._orig_send = send; - local pump = new_pump(session.send, queue_size); - pump:pause(); - session.pump = pump; - function session.send(stanza) - if session.state == "active" or module:fire_event("csi-is-stanza-important", { stanza = stanza, session = session }) then - pump:flush(); - send(stanza); - else - if st.is_stanza(stanza) and stanza.attr.xmlns == nil and stanza.name ~= "iq" then - stanza = st.clone(stanza); - stanza:add_direct_child(st.stanza("delay", {xmlns = "urn:xmpp:delay", from = bare_jid, stamp = dt.datetime()})); - end - pump:push(stanza); - end - return true; - end + stanza = with_timestamp(stanza, jid.join(session.username, session.host)) end + session.csi_counter = ctr + 1; + return stanza; +end + +local function flush_buffer(data, session) + if session.csi_flushing then + return data; + end + session.csi_flushing = true; + session.log("debug", "Client sent something, flushing buffer once (queue size is %d)", session.csi_counter); + session.conn:resume_writes(); + return data; +end + +function enable_optimizations(session) + if session.conn and session.conn.pause_writes then + session.conn:pause_writes(); + filters.add_filter(session, "stanzas/out", manage_buffer); + filters.add_filter(session, "bytes/in", flush_buffer); + else + session.log("warn", "Session connection does not support write pausing"); + end +end + +function disable_optimizations(session) + session.csi_flushing = nil; + filters.remove_filter(session, "stanzas/out", manage_buffer); + filters.remove_filter(session, "bytes/in", flush_buffer); + if session.conn and session.conn.resume_writes then + session.conn:resume_writes(); + end +end + +module:hook("csi-client-inactive", function (event) + local session = event.origin; + enable_optimizations(session); end); module:hook("csi-client-active", function (event) local session = event.origin; - if session.pump then - session.pump:resume(); + disable_optimizations(session); +end); + +module:hook("pre-resource-unbind", function (event) + local session = event.session; + disable_optimizations(session); +end, 1); + +module:hook("c2s-ondrain", function (event) + local session = event.session; + if session.state == "inactive" and session.conn and session.conn.pause_writes then + session.conn:pause_writes(); + session.log("debug", "Buffer flushed, resuming inactive mode (queue size was %d)", session.csi_counter); + session.csi_counter = 0; end end); +function module.load() + for _, user_session in pairs(prosody.hosts[module.host].sessions) do + for _, session in pairs(user_session.sessions) do + if session.state == "inactive" then + enable_optimizations(session); + end + end + end +end + +function module.unload() + for _, user_session in pairs(prosody.hosts[module.host].sessions) do + for _, session in pairs(user_session.sessions) do + if session.state == "inactive" then + disable_optimizations(session); + end + end + end +end diff --git a/plugins/mod_dialback.lua b/plugins/mod_dialback.lua index eddc3209..f580d948 100644 --- a/plugins/mod_dialback.lua +++ b/plugins/mod_dialback.lua @@ -93,6 +93,11 @@ module:hook("stanza/jabber:server:dialback:result", function(event) -- he wants to be identified through dialback -- We need to check the key with the Authoritative server local attr = stanza.attr; + if not attr.to or not attr.from then + origin.log("debug", "Missing Dialback addressing (from=%q, to=%q)", attr.from, attr.to); + origin:close("improper-addressing"); + return true; + end local to, from = nameprep(attr.to), nameprep(attr.from); if not hosts[to] then @@ -102,6 +107,7 @@ module:hook("stanza/jabber:server:dialback:result", function(event) return true; elseif not from then origin:close("improper-addressing"); + return true; end if dwd and origin.secure then diff --git a/plugins/mod_groups.lua b/plugins/mod_groups.lua index 646b7408..0c44f481 100644 --- a/plugins/mod_groups.lua +++ b/plugins/mod_groups.lua @@ -25,7 +25,7 @@ function inject_roster_contacts(event) local function import_jids_to_roster(group_name) for jid in pairs(groups[group_name]) do -- Add them to roster - --module:log("debug", "processing jid %s in group %s", tostring(jid), tostring(group_name)); + --module:log("debug", "processing jid %s in group %s", jid, group_name); if jid ~= bare_jid then if not roster[jid] then roster[jid] = {}; end roster[jid].subscription = "both"; @@ -99,7 +99,7 @@ function module.load() end members[false][#members[false]+1] = curr_group; -- Is a public group end - module:log("debug", "New group: %s", tostring(curr_group)); + module:log("debug", "New group: %s", curr_group); groups[curr_group] = groups[curr_group] or {}; else -- Add JID @@ -108,7 +108,7 @@ function module.load() local jid; jid = jid_prep(entryjid:match("%S+")); if jid then - module:log("debug", "New member of %s: %s", tostring(curr_group), tostring(jid)); + module:log("debug", "New member of %s: %s", curr_group, jid); groups[curr_group][jid] = name or false; members[jid] = members[jid] or {}; members[jid][#members[jid]+1] = curr_group; diff --git a/plugins/mod_http.lua b/plugins/mod_http.lua index a1d409bd..c3e19bb3 100644 --- a/plugins/mod_http.lua +++ b/plugins/mod_http.lua @@ -7,13 +7,16 @@ -- module:set_global(); -module:depends("http_errors"); +pcall(function () + module:depends("http_errors"); +end); local portmanager = require "core.portmanager"; local moduleapi = require "core.moduleapi"; local url_parse = require "socket.url".parse; local url_build = require "socket.url".build; local normalize_path = require "util.http".normalize_path; +local set = require "util.set"; local server = require "net.http.server"; @@ -22,6 +25,12 @@ server.set_default_host(module:get_option_string("http_default_host")); server.set_option("body_size_limit", module:get_option_number("http_max_content_size")); server.set_option("buffer_size_limit", module:get_option_number("http_max_buffer_size")); +-- CORS settigs +local opt_methods = module:get_option_set("access_control_allow_methods", { "GET", "OPTIONS" }); +local opt_headers = module:get_option_set("access_control_allow_headers", { "Content-Type" }); +local opt_credentials = module:get_option_boolean("access_control_allow_credentials", false); +local opt_max_age = module:get_option_number("access_control_max_age", 2 * 60 * 60); + local function get_http_event(host, app_path, key) local method, path = key:match("^(%S+)%s+(.+)$"); if not method then -- No path specified, default to "" (base path) @@ -83,6 +92,16 @@ function moduleapi.http_url(module, app_name, default_path) return "http://disabled.invalid/"; end +local function apply_cors_headers(response, methods, headers, max_age, allow_credentials, origin) + response.headers.access_control_allow_methods = tostring(methods); + response.headers.access_control_allow_headers = tostring(headers); + response.headers.access_control_max_age = tostring(max_age) + response.headers.access_control_allow_origin = origin or "*"; + if allow_credentials then + response.headers.access_control_allow_credentials = "true"; + end +end + function module.add_host(module) local host = module.host; if host ~= "*" then @@ -101,9 +120,27 @@ function module.add_host(module) end apps[app_name] = apps[app_name] or {}; local app_handlers = apps[app_name]; + + local app_methods = opt_methods; + + local function cors_handler(event_data) + local request, response = event_data.request, event_data.response; + apply_cors_headers(response, app_methods, opt_headers, opt_max_age, opt_credentials, request.headers.origin); + end + + local function options_handler(event_data) + cors_handler(event_data); + return ""; + end + for key, handler in pairs(event.item.route or {}) do local event_name = get_http_event(host, app_path, key); if event_name then + local method = event_name:match("^%S+"); + if not app_methods:contains(method) then + app_methods = app_methods + set.new{ method }; + end + local options_event_name = event_name:gsub("^%S+", "OPTIONS"); if type(handler) ~= "function" then local data = handler; handler = function () return data; end @@ -119,8 +156,14 @@ function module.add_host(module) module:hook_object_event(server, event_name:sub(1, -2), redir_handler, -1); end if not app_handlers[event_name] then - app_handlers[event_name] = handler; + app_handlers[event_name] = { + main = handler; + cors = cors_handler; + options = options_handler; + }; module:hook_object_event(server, event_name, handler); + module:hook_object_event(server, event_name, cors_handler, 1); + module:hook_object_event(server, options_event_name, options_handler, -1); else module:log("warn", "App %s added handler twice for '%s', ignoring", app_name, event_name); end @@ -130,7 +173,7 @@ function module.add_host(module) end local services = portmanager.get_active_services(); if services:get("https") or services:get("http") then - module:log("debug", "Serving '%s' at %s", app_name, module:http_url(app_name, app_path)); + module:log("info", "Serving '%s' at %s", app_name, module:http_url(app_name, app_path)); else module:log("warn", "Not listening on any ports, '%s' will be unreachable", app_name); end @@ -139,8 +182,11 @@ function module.add_host(module) local function http_app_removed(event) local app_handlers = apps[event.item.name]; apps[event.item.name] = nil; - for event_name, handler in pairs(app_handlers) do - module:unhook_object_event(server, event_name, handler); + for event_name, handlers in pairs(app_handlers) do + module:unhook_object_event(server, event_name, handlers.main); + module:unhook_object_event(server, event_name, handlers.cors); + local options_event_name = event_name:gsub("^%S+", "OPTIONS"); + module:unhook_object_event(server, options_event_name, handlers.options); end end @@ -195,10 +241,8 @@ module:provides("net", { listener = server.listener; default_port = 5281; encryption = "ssl"; - ssl_config = { - verify = "none"; - }; multiplex = { + protocol = "http/1.1"; pattern = "^[A-Z]"; }; }); diff --git a/plugins/mod_http_errors.lua b/plugins/mod_http_errors.lua index 13473219..e151a68e 100644 --- a/plugins/mod_http_errors.lua +++ b/plugins/mod_http_errors.lua @@ -26,21 +26,24 @@ local html = [[ <meta charset="utf-8"> <title>{title}</title> <style> -body{ - margin-top:14%; - text-align:center; - background-color:#F8F8F8; - font-family:sans-serif; +body { + margin-top : 14%; + text-align : center; + background-color : #F8F8F8; + font-family : sans-serif } -h1{ - font-size:xx-large; + +h1 { + font-size : xx-large } -p{ - font-size:x-large; + +p { + font-size : x-large } + p+p { - font-size:large; - font-family:courier; + font-size : large; + font-family : courier } </style> </head> @@ -70,5 +73,17 @@ module:hook_object_event(server, "http-error", function (event) if event.response then event.response.headers.content_type = "text/html; charset=utf-8"; end - return get_page(event.code, (show_private and event.private_message) or event.message); + return get_page(event.code, (show_private and event.private_message) or event.message or (event.error and event.error.text)); end); + +module:hook_object_event(server, "http-error", function (event) + local request, response = event.request, event.response; + if request and response and request.path == "/" and response.status_code == 404 then + response.headers.content_type = "text/html; charset=utf-8"; + return render(html, { + title = "Prosody is running!"; + message = "Welcome to the XMPP world!"; + }); + end +end, 1); + diff --git a/plugins/mod_http_files.lua b/plugins/mod_http_files.lua index a8398c01..4d0b14cd 100644 --- a/plugins/mod_http_files.lua +++ b/plugins/mod_http_files.lua @@ -7,14 +7,9 @@ -- module:depends("http"); -local server = require"net.http.server"; -local lfs = require "lfs"; -local os_date = os.date; local open = io.open; -local stat = lfs.attributes; -local build_path = require"socket.url".build_path; -local path_sep = package.config:sub(1,1); +local fileserver = require"net.http.files"; local base_path = module:get_option_path("http_files_dir", module:get_option_path("http_path")); local cache_size = module:get_option_number("http_files_cache_size", 128); @@ -38,7 +33,9 @@ if not mime_map then module:shared("/*/http_files/mime").types = mime_map; local mime_types, err = open(module:get_option_path("mime_types_file", "/etc/mime.types", "config"), "r"); - if mime_types then + if not mime_types then + module:log("debug", "Could not open MIME database: %s", err); + else local mime_data = mime_types:read("*a"); mime_types:close(); setmetatable(mime_map, { @@ -51,148 +48,56 @@ if not mime_map then end end -local forbidden_chars_pattern = "[/%z]"; -if prosody.platform == "windows" then - forbidden_chars_pattern = "[/%z\001-\031\127\"*:<>?|]" +local function get_calling_module() + local info = debug.getinfo(3, "S"); + if not info then return "An unknown module"; end + return info.source:match"mod_[^/\\.]+" or info.short_src; end -local urldecode = require "util.http".urldecode; -function sanitize_path(path) - if not path then return end - local out = {}; - - local c = 0; - for component in path:gmatch("([^/]+)") do - component = urldecode(component); - if component:find(forbidden_chars_pattern) then - return nil; - elseif component == ".." then - if c <= 0 then - return nil; - end - out[c] = nil; - c = c - 1; - elseif component ~= "." then - c = c + 1; - out[c] = component; - end - end - if path:sub(-1,-1) == "/" then - out[c+1] = ""; - end - return "/"..table.concat(out, "/"); -end - -local cache = require "util.cache".new(cache_size); - +-- COMPAT -- TODO deprecate function serve(opts) if type(opts) ~= "table" then -- assume path string opts = { path = opts }; end - -- luacheck: ignore 431 - local base_path = opts.path; - local dir_indices = opts.index_files or dir_indices; - local directory_index = opts.directory_index; - local function serve_file(event, path) - local request, response = event.request, event.response; - local sanitized_path = sanitize_path(path); - if path and not sanitized_path then - return 400; - end - path = sanitized_path; - local orig_path = sanitize_path(request.path); - local full_path = base_path .. (path or ""):gsub("/", path_sep); - local attr = stat(full_path:match("^.*[^\\/]")); -- Strip trailing path separator because Windows - if not attr then - return 404; - end - - local request_headers, response_headers = request.headers, response.headers; - - local last_modified = os_date('!%a, %d %b %Y %H:%M:%S GMT', attr.modification); - response_headers.last_modified = last_modified; - - local etag = ('"%x-%x-%x"'):format(attr.change or 0, attr.size or 0, attr.modification or 0); - response_headers.etag = etag; - - local if_none_match = request_headers.if_none_match - local if_modified_since = request_headers.if_modified_since; - if etag == if_none_match - or (not if_none_match and last_modified == if_modified_since) then - return 304; - end - - local data = cache:get(orig_path); - if data and data.etag == etag then - response_headers.content_type = data.content_type; - data = data.data; - elseif attr.mode == "directory" and path then - if full_path:sub(-1) ~= "/" then - local dir_path = { is_absolute = true, is_directory = true }; - for dir in orig_path:gmatch("[^/]+") do dir_path[#dir_path+1]=dir; end - response_headers.location = build_path(dir_path); - return 301; - end - for i=1,#dir_indices do - if stat(full_path..dir_indices[i], "mode") == "file" then - return serve_file(event, path..dir_indices[i]); - end - end - - if directory_index then - data = server._events.fire_event("directory-index", { path = request.path, full_path = full_path }); - end - if not data then - return 403; - end - cache:set(orig_path, { data = data, content_type = mime_map.html; etag = etag; }); - response_headers.content_type = mime_map.html; - - else - local f, err = open(full_path, "rb"); - if not f then - module:log("debug", "Could not open %s. Error was %s", full_path, err); - return 403; - end - local ext = full_path:match("%.([^./]+)$"); - local content_type = ext and mime_map[ext]; - response_headers.content_type = content_type; - if attr.size > cache_max_file_size then - response_headers.content_length = attr.size; - module:log("debug", "%d > cache_max_file_size", attr.size); - return response:send_file(f); - else - data = f:read("*a"); - f:close(); - end - cache:set(orig_path, { data = data; content_type = content_type; etag = etag }); - end - - return response:send(data); + if opts.directory_index == nil then + opts.directory_index = directory_index; end - - return serve_file; + if opts.mime_map == nil then + opts.mime_map = mime_map; + end + if opts.cache_size == nil then + opts.cache_size = cache_size; + end + if opts.cache_max_file_size == nil then + opts.cache_max_file_size = cache_max_file_size; + end + if opts.index_files == nil then + opts.index_files = dir_indices; + end + -- TODO Crank up to warning + module:log("debug", "%s should be updated to use 'net.http.files' insead of mod_http_files", get_calling_module()); + return fileserver.serve(opts); end function wrap_route(routes) + module:log("debug", "%s should be updated to use 'net.http.files' insead of mod_http_files", get_calling_module()); for route,handler in pairs(routes) do if type(handler) ~= "function" then - routes[route] = serve(handler); + routes[route] = fileserver.serve(handler); end end return routes; end -if base_path then - module:provides("http", { - route = { - ["GET /*"] = serve { - path = base_path; - directory_index = directory_index; - } - }; - }); -else - module:log("debug", "http_files_dir not set, assuming use by some other module"); -end - +module:provides("http", { + route = { + ["GET /*"] = fileserver.serve({ + path = base_path; + directory_index = directory_index; + mime_map = mime_map; + cache_size = cache_size; + cache_max_file_size = cache_max_file_size; + index_files = dir_indices; + }); + }; +}); diff --git a/plugins/mod_lastactivity.lua b/plugins/mod_lastactivity.lua index 575e66be..91d11bd2 100644 --- a/plugins/mod_lastactivity.lua +++ b/plugins/mod_lastactivity.lua @@ -30,7 +30,7 @@ module:hook("iq-get/bare/jabber:iq:last:query", function(event) if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then local seconds, text = "0", ""; if map[username] then - seconds = tostring(os.difftime(os.time(), map[username].t)); + seconds = string.format("%d", os.difftime(os.time(), map[username].t)); text = map[username].s; end origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=seconds}):text(text)); diff --git a/plugins/mod_legacyauth.lua b/plugins/mod_legacyauth.lua index 0f41d3e7..941806d3 100644 --- a/plugins/mod_legacyauth.lua +++ b/plugins/mod_legacyauth.lua @@ -78,8 +78,10 @@ module:hook("stanza/iq/jabber:iq:auth:query", function(event) session:close(); -- FIXME undo resource bind and auth instead of closing the session? return true; end + session.send(st.reply(stanza)); + else + session.send(st.error_reply(stanza, "auth", "not-authorized", err)); end - session.send(st.reply(stanza)); else session.send(st.error_reply(stanza, "auth", "not-authorized")); end diff --git a/plugins/mod_limits.lua b/plugins/mod_limits.lua index 914d5c44..024ab686 100644 --- a/plugins/mod_limits.lua +++ b/plugins/mod_limits.lua @@ -32,7 +32,7 @@ local function parse_burst(burst, sess_type) end local n_burst = tonumber(burst); if not n_burst then - module:log("error", "Unable to parse burst for %s: %q, using default burst interval (%ds)", sess_type, tostring(burst), default_burst); + module:log("error", "Unable to parse burst for %s: %q, using default burst interval (%ds)", sess_type, burst, default_burst); end return n_burst or default_burst; end @@ -51,18 +51,18 @@ end local default_filter_set = {}; function default_filter_set.bytes_in(bytes, session) - local sess_throttle = session.throttle; - if sess_throttle then - local ok, balance, outstanding = sess_throttle:poll(#bytes, true); + local sess_throttle = session.throttle; + if sess_throttle then + local ok, _, outstanding = sess_throttle:poll(#bytes, true); if not ok then - session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding); + session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding); outstanding = ceil(outstanding); session.conn:pause(); -- Read no more data from the connection until there is no outstanding data local outstanding_data = bytes:sub(-outstanding); bytes = bytes:sub(1, #bytes-outstanding); timer.add_task(limits_resolution, function () if not session.conn then return; end - if sess_throttle:peek(#outstanding_data) then + if sess_throttle:peek(#outstanding_data) then session.log("debug", "Resuming paused session"); session.conn:resume(); end @@ -84,8 +84,13 @@ local function filter_hook(session) local session_type = session.type:match("^[^_]+"); local filter_set, opts = type_filters[session_type], limits[session_type]; if opts then - session.throttle = throttle.create(opts.bytes_per_second * opts.burst_seconds, opts.burst_seconds); - filters.add_filter(session, "bytes/in", filter_set.bytes_in, 1000); + if session.conn and session.conn.setlimit then + session.conn:setlimit(opts.bytes_per_second); + -- Currently no burst support + else + session.throttle = throttle.create(opts.bytes_per_second * opts.burst_seconds, opts.burst_seconds); + filters.add_filter(session, "bytes/in", filter_set.bytes_in, 1000); + end end end @@ -96,3 +101,25 @@ end function module.unload() filters.remove_filter_hook(filter_hook); end + +function module.add_host(module) + local unlimited_jids = module:get_option_inherited_set("unlimited_jids", {}); + + if not unlimited_jids:empty() then + module:hook("authentication-success", function (event) + local session = event.session; + local session_type = session.type:match("^[^_]+"); + local jid = session.username .. "@" .. session.host; + if unlimited_jids:contains(jid) then + if session.conn and session.conn.setlimit then + session.conn:setlimit(0); + -- Currently no burst support + else + local filter_set = type_filters[session_type]; + filters.remove_filter(session, "bytes/in", filter_set.bytes_in); + session.throttle = nil; + end + end + end); + end +end diff --git a/plugins/mod_mam/mod_mam.lua b/plugins/mod_mam/mod_mam.lua index e7d89a95..72b7639a 100644 --- a/plugins/mod_mam/mod_mam.lua +++ b/plugins/mod_mam/mod_mam.lua @@ -25,6 +25,7 @@ local jid_bare = require "util.jid".bare; local jid_split = require "util.jid".split; local jid_prepped_split = require "util.jid".prepped_split; local dataform = require "util.dataforms".new; +local get_form_type = require "util.dataforms".get_type; local host = module.host; local rm_load_roster = require "core.rostermanager".load_roster; @@ -40,6 +41,9 @@ local strip_tags = module:get_option_set("dont_archive_namespaces", { "http://ja 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 cleanup_interval = module:get_option_number("archive_cleanup_interval", 4 * 60 * 60); +local archive_item_limit = module:get_option_number("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000); if not archive.find then error("mod_"..(archive._provided_by or archive.name and "storage_"..archive.name).." does not support archiving\n" .."See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information"); @@ -98,7 +102,14 @@ module:hook("iq-set/self/"..xmlns_mam..":query", function(event) local qwith, qstart, qend; local form = query:get_child("x", "jabber:x:data"); if form then - local err; + local form_type, err = get_form_type(form); + if not form_type then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err)); + return true; + elseif form_type ~= xmlns_mam then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_mam.."'")); + return true; + end form, err = query_form:data(form); if err then origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err)))); @@ -117,10 +128,12 @@ module:hook("iq-set/self/"..xmlns_mam..":query", function(event) qstart, qend = vstart, vend; end - module:log("debug", "Archive query, id %s with %s from %s until %s", - tostring(qid), qwith or "anyone", - qstart and timestamp(qstart) or "the dawn of time", - qend and timestamp(qend) or "now"); + module:log("debug", "Archive query by %s id=%s with=%s when=%s...%s", + origin.username, + qid or stanza.attr.id, + qwith or "*", + qstart and timestamp(qstart) or "", + qend and timestamp(qend) or ""); -- RSM stuff local qset = rsm.get(query); @@ -128,6 +141,9 @@ module:hook("iq-set/self/"..xmlns_mam..":query", function(event) local reverse = qset and qset.before or false; local before, after = qset and qset.before, qset and qset.after; if type(before) ~= "string" then before = nil; end + if qset then + module:log("debug", "Archive query id=%s rsm=%q", qid or stanza.attr.id, qset); + end -- Load all the data! local data, err = archive:find(origin.username, { @@ -140,7 +156,12 @@ module:hook("iq-set/self/"..xmlns_mam..":query", function(event) }); if not data then - origin.send(st.error_reply(stanza, "cancel", "internal-server-error", err)); + module:log("debug", "Archive query id=%s failed: %s", qid or stanza.attr.id, err); + if err == "item-not-found" then + origin.send(st.error_reply(stanza, "modify", "item-not-found")); + else + origin.send(st.error_reply(stanza, "cancel", "internal-server-error")); + end return true; end local total = tonumber(err); @@ -189,13 +210,13 @@ module:hook("iq-set/self/"..xmlns_mam..":query", function(event) first, last = last, first; end - -- That's all folks! - module:log("debug", "Archive query %s completed", tostring(qid)); - origin.send(st.reply(stanza) :tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete }) :add_child(rsm.generate { first = first, last = last, count = total })); + + -- That's all folks! + module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1); return true; end); @@ -213,13 +234,13 @@ local function shall_store(user, who) end local prefs = get_prefs(user); local rule = prefs[who]; - module:log("debug", "%s's rule for %s is %s", user, who, tostring(rule)); + module:log("debug", "%s's rule for %s is %s", user, who, rule); if rule ~= nil then return rule; end -- Below could be done by a metatable local default = prefs[false]; - module:log("debug", "%s's default rule is %s", user, tostring(default)); + module:log("debug", "%s's default rule is %s", user, default); if default == "roster" then return has_in_roster(user, who); end @@ -242,11 +263,66 @@ local function strip_stanza_id(stanza, user) return stanza; end +local function should_store(stanza, c2s) --> boolean, reason: string + local st_type = stanza.attr.type or "normal"; + -- FIXME pass direction of stanza and use that along with bare/full JID addressing + -- for more accurate MUC / type=groupchat check + + if st_type == "headline" then + -- Headline messages are ephemeral by definition + return false, "headline"; + end + if st_type == "error" and not c2s then + -- Store delivery failure notifications so you know if your own messages were not delivered + return true, "bounce"; + end + if st_type == "groupchat" then + -- MUC messages always go to the full JID, usually archived by the MUC + return false, "groupchat"; + end + if stanza:get_child("no-store", "urn:xmpp:hints") + or stanza:get_child("no-permanent-store", "urn:xmpp:hints") then + -- XXX Experimental XEP + return false, "hint"; + end + if stanza:get_child("store", "urn:xmpp:hints") then + return true, "hint"; + end + if stanza:get_child("body") then + return true, "body"; + end + if stanza:get_child("subject") then + -- XXX Who would send a message with a subject but without a body? + return true, "subject"; + end + if stanza:get_child("encryption", "urn:xmpp:eme:0") then + -- Since we can't know what an encrypted message contains, we assume it's important + -- XXX Experimental XEP + return true, "encrypted"; + end + if stanza:get_child(nil, "urn:xmpp:receipts") then + -- If it's important enough to ask for a receipt then it's important enough to archive + -- and the same applies to the receipt + return true, "receipt"; + end + if stanza:get_child(nil, "urn:xmpp:chat-markers:0") then + -- XXX Experimental XEP + return true, "marker"; + end + if stanza:get_child("x", "jabber:x:conference") + or stanza:find("{http://jabber.org/protocol/muc#user}x/invite") then + return true, "invite"; + end + + -- The IM-NG thing to do here would be to return `not st_to_full` + -- One day ... + return false, "default"; +end + -- Handle messages local function message_handler(event, c2s) local origin, stanza = event.origin, event.stanza; local log = c2s and origin.log or module._log; - local orig_type = stanza.attr.type or "normal"; local orig_from = stanza.attr.from; local orig_to = stanza.attr.to or orig_from; -- Stanza without 'to' are treated as if it was to their own bare jid @@ -259,21 +335,12 @@ local function message_handler(event, c2s) -- Filter out <stanza-id> that claim to be from us event.stanza = strip_stanza_id(stanza, store_user); - -- We store chat messages or normal messages that have a body - if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body")) ) then - log("debug", "Not archiving stanza: %s (type)", stanza:top_tag()); + local should, why = should_store(stanza, c2s); + if not should then + log("debug", "Not archiving stanza: %s (%s)", stanza:top_tag(), why); return; end - -- or if hints suggest we shouldn't - if not stanza:get_child("store", "urn:xmpp:hints") then -- No hint telling us we should store - if stanza:get_child("no-permanent-store", "urn:xmpp:hints") - or stanza:get_child("no-store", "urn:xmpp:hints") then -- Hint telling us we should NOT store - log("debug", "Not archiving stanza: %s (hint)", stanza:top_tag()); - return; - end - end - local clone_for_storage; if not strip_tags:empty() then clone_for_storage = st.clone(stanza); @@ -294,10 +361,31 @@ local function message_handler(event, c2s) -- Check with the users preferences if shall_store(store_user, with) then - log("debug", "Archiving stanza: %s", stanza:top_tag()); + log("debug", "Archiving stanza: %s (%s)", stanza:top_tag(), why); -- And stash it - local ok, err = archive:append(store_user, nil, clone_for_storage, time_now(), with); + local time = time_now(); + local ok, err = archive:append(store_user, nil, clone_for_storage, time, with); + if not ok and err == "quota-limit" then + if type(cleanup_after) == "number" then + module:log("debug", "User '%s' over quota, cleaning archive", store_user); + local cleaned = archive:delete(store_user, { + ["end"] = (os.time() - cleanup_after); + }); + if cleaned then + ok, err = archive:append(store_user, nil, clone_for_storage, time, with); + end + end + if not ok and (archive.caps and archive.caps.truncate) then + module:log("debug", "User '%s' over quota, truncating archive", store_user); + local truncated = archive:delete(store_user, { + truncate = archive_item_limit - 1; + }); + if truncated then + ok, err = archive:append(store_user, nil, clone_for_storage, time, with); + end + end + end if ok then local clone_for_other_handlers = st.clone(stanza); local id = ok; @@ -325,8 +413,6 @@ end module:hook("pre-message/bare", strip_stanza_id_after_other_events, -1); module:hook("pre-message/full", strip_stanza_id_after_other_events, -1); -local cleanup_after = module:get_option_string("archive_expires_after", "1w"); -local cleanup_interval = module:get_option_number("archive_cleanup_interval", 4 * 60 * 60); if cleanup_after ~= "never" then local cleanup_storage = module:open_store("archive_cleanup"); local cleanup_map = module:open_store("archive_cleanup", "map"); @@ -361,9 +447,11 @@ if cleanup_after ~= "never" then last_date:set(username, date); end end + local cleanup_time = module:measure("cleanup", "times"); local async = require "util.async"; cleanup_runner = async.runner(function () + local cleanup_done = cleanup_time(); local users = {}; local cut_off = datestamp(os.time() - cleanup_after); for date in cleanup_storage:users() do @@ -397,6 +485,7 @@ if cleanup_after ~= "never" then wait(); end module:log("info", "Deleted %d expired messages for %d users", sum, num_users); + cleanup_done(); end); cleanup_task = module:add_timer(1, function () diff --git a/plugins/mod_mimicking.lua b/plugins/mod_mimicking.lua new file mode 100644 index 00000000..b586a70c --- /dev/null +++ b/plugins/mod_mimicking.lua @@ -0,0 +1,85 @@ +-- Prosody IM +-- Copyright (C) 2012 Florian Zeitz +-- Copyright (C) 2019 Kim Alvefur +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local encodings = require "util.encodings"; +assert(encodings.confusable, "This module requires that Prosody be built with ICU"); +local skeleton = encodings.confusable.skeleton; + +local usage = require "util.prosodyctl".show_usage; +local usermanager = require "core.usermanager"; +local storagemanager = require "core.storagemanager"; + +local skeletons +function module.load() + if module.host ~= "*" then + skeletons = module:open_store("skeletons"); + end +end + +module:hook("user-registered", function(user) + local skel = skeleton(user.username); + local ok, err = skeletons:set(skel, { username = user.username }); + if not ok then + module:log("error", "Unable to store mimicry data (%q => %q): %s", user.username, skel, err); + end +end); + +module:hook("user-deleted", function(user) + local skel = skeleton(user.username); + local ok, err = skeletons:set(skel, nil); + if not ok and err then + module:log("error", "Unable to clear mimicry data (%q): %s", skel, err); + end +end); + +module:hook("user-registering", function(user) + local existing, err = skeletons:get(skeleton(user.username)); + if existing then + module:log("debug", "Attempt to register username '%s' which could be confused with '%s'", user.username, existing.username); + user.allowed = false; + elseif err then + module:log("error", "Unable to check if new username '%s' can be confused with any existing user: %s", err); + end +end); + +function module.command(arg) + if (arg[1] ~= "bootstrap" or not arg[2]) then + usage("mod_mimicking bootstrap <host>", "Initialize username mimicry database"); + return; + end + + local host = arg[2]; + + local host_session = prosody.hosts[host]; + if not host_session then + return "No such host"; + end + + storagemanager.initialize_host(host); + usermanager.initialize_host(host); + + skeletons = storagemanager.open(host, "skeletons"); + + local count = 0; + for user in usermanager.users(host) do + local skel = skeleton(user); + local existing, err = skeletons:get(skel); + if existing and existing.username ~= user then + module:log("warn", "Existing usernames '%s' and '%s' are confusable", existing.username, user); + elseif err then + module:log("error", "Error checking for existing mimicry data (%q = %q): %s", user, skel, err); + end + local ok, err = skeletons:set(skel, { username = user }); + if ok then + count = count + 1; + elseif err then + module:log("error", "Unable to store mimicry data (%q => %q): %s", user, skel, err); + end + end + module:log("info", "%d usernames indexed", count); +end diff --git a/plugins/mod_muc_mam.lua b/plugins/mod_muc_mam.lua index 5a01324d..7eeada6d 100644 --- a/plugins/mod_muc_mam.lua +++ b/plugins/mod_muc_mam.lua @@ -4,7 +4,7 @@ -- This file is MIT/X11 licensed. if module:get_host_type() ~= "component" then - module:log("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name); + module:log_status("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name); return; end @@ -21,6 +21,7 @@ local jid_bare = require "util.jid".bare; local jid_split = require "util.jid".split; local jid_prep = require "util.jid".prep; local dataform = require "util.dataforms".new; +local get_form_type = require "util.dataforms".get_type; local mod_muc = module:depends"muc"; local get_room_from_jid = mod_muc.get_room_from_jid; @@ -32,6 +33,9 @@ local m_min = math.min; local timestamp, timestamp_parse, datestamp = import( "util.datetime", "datetime", "parse", "date"); local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50); +local cleanup_after = module:get_option_string("muc_log_expires_after", "1w"); +local cleanup_interval = module:get_option_number("muc_log_cleanup_interval", 4 * 60 * 60); + local default_history_length = 20; local max_history_length = module:get_option_number("max_history_messages", math.huge); @@ -49,6 +53,8 @@ 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); + if archive.name == "null" or not archive.find then if not archive.find then module:log("error", "Attempt to open archive storage returned a driver without archive API support"); @@ -63,12 +69,15 @@ end local function archiving_enabled(room) if log_all_rooms then + module:log("debug", "Archiving all rooms"); return true; end local enabled = room._data.archiving; if enabled == nil then + module:log("debug", "Default is %s (for %s)", log_by_default, room.jid); return log_by_default; end + module:log("debug", "Logging in room %s is %s", room.jid, enabled); return enabled; end @@ -135,7 +144,14 @@ module:hook("iq-set/bare/"..xmlns_mam..":query", function(event) local qstart, qend; local form = query:get_child("x", "jabber:x:data"); if form then - local err; + local form_type, err = get_form_type(form); + if not form_type then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err)); + return true; + elseif form_type ~= xmlns_mam then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_mam.."'")); + return true; + end form, err = query_form:data(form); if err then origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err)))); @@ -153,10 +169,11 @@ module:hook("iq-set/bare/"..xmlns_mam..":query", function(event) qstart, qend = vstart, vend; end - module:log("debug", "Archive query id %s from %s until %s)", - tostring(qid), - qstart and timestamp(qstart) or "the dawn of time", - qend and timestamp(qend) or "now"); + module:log("debug", "Archive query by %s id=%s when=%s...%s", + origin.username, + qid or stanza.attr.id, + qstart and timestamp(qstart) or "", + qend and timestamp(qend) or ""); -- RSM stuff local qset = rsm.get(query); @@ -165,6 +182,9 @@ module:hook("iq-set/bare/"..xmlns_mam..":query", function(event) local before, after = qset and qset.before, qset and qset.after; if type(before) ~= "string" then before = nil; end + if qset then + module:log("debug", "Archive query id=%s rsm=%q", qid or stanza.attr.id, qset); + end -- Load all the data! local data, err = archive:find(room_node, { @@ -176,7 +196,12 @@ module:hook("iq-set/bare/"..xmlns_mam..":query", function(event) }); if not data then - origin.send(st.error_reply(stanza, "cancel", "internal-server-error")); + module:log("debug", "Archive query id=%s failed: %s", qid or stanza.attr.id, err); + if err == "item-not-found" then + origin.send(st.error_reply(stanza, "modify", "item-not-found")); + else + origin.send(st.error_reply(stanza, "cancel", "internal-server-error")); + end return true; end local total = tonumber(err); @@ -233,13 +258,14 @@ module:hook("iq-set/bare/"..xmlns_mam..":query", function(event) first, last = last, first; end - -- That's all folks! - module:log("debug", "Archive query %s completed", tostring(qid)); origin.send(st.reply(stanza) :tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete }) :add_child(rsm.generate { first = first, last = last, count = total })); + + -- That's all folks! + module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1); return true; end); @@ -274,7 +300,7 @@ module:hook("muc-get-history", function (event) local data, err = archive:find(jid_split(room_jid), query); if not data then - module:log("error", "Could not fetch history: %s", tostring(err)); + module:log("error", "Could not fetch history: %s", err); return end @@ -300,7 +326,7 @@ module:hook("muc-get-history", function (event) maxchars = maxchars - chars; end history[i], i = item, i+1; - -- module:log("debug", tostring(item)); + -- module:log("debug", item); end function event.next_stanza() i = i - 1; @@ -328,7 +354,7 @@ end, 0); -- Handle messages local function save_to_history(self, stanza) - local room_node, room_host = jid_split(self.jid); + local room_node = jid_split(self.jid); local stored_stanza = stanza; @@ -355,7 +381,29 @@ local function save_to_history(self, stanza) end -- And stash it - local id, err = archive:append(room_node, nil, stored_stanza, time_now(), with); + local time = time_now(); + local id, err = archive:append(room_node, nil, stored_stanza, time, with); + + if not id and err == "quota-limit" then + if type(cleanup_after) == "number" then + module:log("debug", "Room '%s' over quota, cleaning archive", room_node); + local cleaned = archive:delete(room_node, { + ["end"] = (os.time() - cleanup_after); + }); + if cleaned then + id, err = archive:append(room_node, nil, stored_stanza, time, with); + end + end + if not id and (archive.caps and archive.caps.truncate) then + module:log("debug", "User '%s' over quota, truncating archive", room_node); + local truncated = archive:delete(room_node, { + truncate = archive_item_limit - 1; + }); + if truncated then + id, err = archive:append(room_node, nil, stored_stanza, time, with); + end + end + end if id then schedule_cleanup(room_node); @@ -394,14 +442,13 @@ end module:add_feature(xmlns_mam); module:hook("muc-disco#info", function(event) - event.reply:tag("feature", {var=xmlns_mam}):up(); + if archiving_enabled(event.room) then + event.reply:tag("feature", {var=xmlns_mam}):up(); + end end); -- Cleanup -local cleanup_after = module:get_option_string("muc_log_expires_after", "1w"); -local cleanup_interval = module:get_option_number("muc_log_cleanup_interval", 4 * 60 * 60); - if cleanup_after ~= "never" then local cleanup_storage = module:open_store("muc_log_cleanup"); local cleanup_map = module:open_store("muc_log_cleanup", "map"); @@ -437,8 +484,11 @@ if cleanup_after ~= "never" then end end + local cleanup_time = module:measure("cleanup", "times"); + local async = require "util.async"; cleanup_runner = async.runner(function () + local cleanup_done = cleanup_time(); local rooms = {}; local cut_off = datestamp(os.time() - cleanup_after); for date in cleanup_storage:users() do @@ -472,6 +522,7 @@ if cleanup_after ~= "never" then wait(); end module:log("info", "Deleted %d expired messages for %d rooms", sum, num_rooms); + cleanup_done(); end); cleanup_task = module:add_timer(1, function () diff --git a/plugins/mod_net_multiplex.lua b/plugins/mod_net_multiplex.lua index 8ef77883..849b22ee 100644 --- a/plugins/mod_net_multiplex.lua +++ b/plugins/mod_net_multiplex.lua @@ -1,22 +1,37 @@ module:set_global(); +local array = require "util.array"; local max_buffer_len = module:get_option_number("multiplex_buffer_size", 1024); local portmanager = require "core.portmanager"; local available_services = {}; +local service_by_protocol = {}; +local available_protocols = array(); local function add_service(service) local multiplex_pattern = service.multiplex and service.multiplex.pattern; + local protocol_name = service.multiplex and service.multiplex.protocol; + if protocol_name then + module:log("debug", "Adding multiplex service %q with protocol %q", service.name, protocol_name); + service_by_protocol[protocol_name] = service; + available_protocols:push(protocol_name); + end if multiplex_pattern then module:log("debug", "Adding multiplex service %q with pattern %q", service.name, multiplex_pattern); available_services[service] = multiplex_pattern; - else + elseif not protocol_name then module:log("debug", "Service %q is not multiplex-capable", service.name); end end module:hook("service-added", function (event) add_service(event.service); end); -module:hook("service-removed", function (event) available_services[event.service] = nil; end); +module:hook("service-removed", function (event) + available_services[event.service] = nil; + if event.service.multiplex and event.service.multiplex.protocol then + available_protocols:filter(function (p) return p ~= event.service.multiplex.protocol end); + service_by_protocol[event.service.multiplex.protocol] = nil; + end +end); for _, services in pairs(portmanager.get_registered_services()) do for _, service in ipairs(services) do @@ -28,7 +43,19 @@ local buffers = {}; local listener = { default_mode = "*a" }; -function listener.onconnect() +function listener.onconnect(conn) + local sock = conn:socket(); + if sock.getalpn then + local selected_proto = sock:getalpn(); + local service = service_by_protocol[selected_proto]; + if service then + module:log("debug", "Routing incoming connection to %s based on ALPN %q", service.name, selected_proto); + local next_listener = service.listener; + conn:setlistener(next_listener); + local onconnect = next_listener.onconnect; + if onconnect then return onconnect(conn) end + end + end end function listener.onincoming(conn, data) @@ -68,5 +95,10 @@ module:provides("net", { name = "multiplex_ssl"; config_prefix = "ssl"; encryption = "ssl"; + ssl_config = { + alpn = function () + return available_protocols; + end; + }; listener = listener; }); diff --git a/plugins/mod_offline.lua b/plugins/mod_offline.lua index 487098d1..dffe8357 100644 --- a/plugins/mod_offline.lua +++ b/plugins/mod_offline.lua @@ -24,11 +24,16 @@ module:hook("message/offline/handle", function(event) node = origin.username; end - return offline_messages:append(node, nil, stanza, os.time(), ""); + local ok = offline_messages:append(node, nil, stanza, os.time(), ""); + if ok then + module:log("debug", "Saved to offline storage: %s", stanza:top_tag()); + end + return ok; end, -1); module:hook("message/offline/broadcast", function(event) local origin = event.origin; + origin.log("debug", "Broadcasting offline messages"); local node, host = origin.username, origin.host; @@ -38,6 +43,9 @@ module:hook("message/offline/broadcast", function(event) stanza:tag("delay", {xmlns = "urn:xmpp:delay", from = host, stamp = datetime.datetime(when)}):up(); -- XEP-0203 origin.send(stanza); end - offline_messages:delete(node); + local ok = offline_messages:delete(node); + if type(ok) == "number" and ok > 0 then + origin.log("debug", "%d offline messages consumed"); + end return true; end, -1); diff --git a/plugins/mod_pep.lua b/plugins/mod_pep.lua index f8047a92..db4fdc87 100644 --- a/plugins/mod_pep.lua +++ b/plugins/mod_pep.lua @@ -8,6 +8,7 @@ local calculate_hash = require "util.caps".calculate_hash; local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; local cache = require "util.cache"; local set = require "util.set"; +local new_id = require "util.id".medium; local storagemanager = require "core.storagemanager"; local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; @@ -123,9 +124,6 @@ local function get_broadcaster(username) if kind == "retract" then kind = "items"; -- XEP-0060 signals retraction in an <items> container end - local message = st.message({ from = user_bare, type = "headline" }) - :tag("event", { xmlns = xmlns_pubsub_event }) - :tag(kind, { node = node }); if item then item = st.clone(item); item.attr.xmlns = nil; -- Clear the pubsub namespace @@ -134,10 +132,19 @@ local function get_broadcaster(username) item:maptags(function () return nil; end); end end + end + + local id = new_id(); + local message = st.message({ from = user_bare, type = "headline", id = id }) + :tag("event", { xmlns = xmlns_pubsub_event }) + :tag(kind, { node = node }); + + if item then message:add_child(item); end + for jid in pairs(jids) do - module:log("debug", "Sending notification to %s from %s: %s", jid, user_bare, tostring(item)); + module:log("debug", "Sending notification to %s from %s for node %s", jid, user_bare, node); message.attr.to = jid; module:send(message); end @@ -166,12 +173,12 @@ local function get_subscriber_filter(username) end function get_pep_service(username) - module:log("debug", "get_pep_service(%q)", username); local user_bare = jid_join(username, host); local service = services[username]; if service then return service; end + module:log("debug", "Creating pubsub service for user %q", username); service = pubsub.new({ pep_username = username; node_defaults = { @@ -238,8 +245,6 @@ end module:hook("iq/bare/"..xmlns_pubsub..":pubsub", handle_pubsub_iq); module:hook("iq/bare/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq); -module:add_identity("pubsub", "pep", module:get_option_string("name", "Prosody")); -module:add_feature("http://jabber.org/protocol/pubsub#publish"); local function get_caps_hash_from_presence(stanza, current) local t = stanza.attr.type; diff --git a/plugins/mod_pep_simple.lua b/plugins/mod_pep_simple.lua index f0b5d7ef..e686b99b 100644 --- a/plugins/mod_pep_simple.lua +++ b/plugins/mod_pep_simple.lua @@ -14,6 +14,7 @@ local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed local pairs = pairs; local next = next; local type = type; +local unpack = table.unpack or unpack; -- luacheck: ignore 113 local calculate_hash = require "util.caps".calculate_hash; local core_post_stanza = prosody.core_post_stanza; local bare_sessions = prosody.bare_sessions; @@ -84,6 +85,7 @@ local function publish_all(user, recipient, session) if d and notify then for node in pairs(notify) do if d[node] then + -- luacheck: ignore id local id, item = unpack(d[node]); session.send(st.message({from=user, to=recipient, type='headline'}) :tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'}) @@ -229,13 +231,13 @@ module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function(event) return true; else --invalid request session.send(st.error_reply(stanza, 'modify', 'bad-request')); - module:log("debug", "Invalid request: %s", tostring(payload)); + module:log("debug", "Invalid request: %s", payload); return true; end else --no presence subscription session.send(st.error_reply(stanza, 'auth', 'not-authorized') :tag('presence-subscription-required', {xmlns='http://jabber.org/protocol/pubsub#errors'})); - module:log("debug", "Unauthorized request: %s", tostring(payload)); + module:log("debug", "Unauthorized request: %s", payload); return true; end end diff --git a/plugins/mod_ping.lua b/plugins/mod_ping.lua index 5fff58d1..b6ccc928 100644 --- a/plugins/mod_ping.lua +++ b/plugins/mod_ping.lua @@ -11,23 +11,9 @@ local st = require "util.stanza"; module:add_feature("urn:xmpp:ping"); local function ping_handler(event) - return event.origin.send(st.reply(event.stanza)); + event.origin.send(st.reply(event.stanza)); + return true; end module:hook("iq-get/bare/urn:xmpp:ping:ping", ping_handler); module:hook("iq-get/host/urn:xmpp:ping:ping", ping_handler); - --- Ad-hoc command - -local datetime = require "util.datetime".datetime; - -function ping_command_handler (self, data, state) -- luacheck: ignore 212 - local now = datetime(); - return { info = "Pong\n"..now, status = "completed" }; -end - -module:depends "adhoc"; -local adhoc_new = module:require "adhoc".new; -local descriptor = adhoc_new("Ping", "ping", ping_command_handler); -module:provides("adhoc", descriptor); - diff --git a/plugins/mod_posix.lua b/plugins/mod_posix.lua index fe826c22..5177aaa5 100644 --- a/plugins/mod_posix.lua +++ b/plugins/mod_posix.lua @@ -20,7 +20,6 @@ if not have_signal then module:log("warn", "Couldn't load signal library, won't respond to SIGTERM"); end -local format = require "util.format".format; local lfs = require "lfs"; local stat = lfs.attributes; @@ -113,24 +112,15 @@ local function write_pidfile() end end -local syslog_opened; -function syslog_sink_maker(config) -- luacheck: ignore 212/config - if not syslog_opened then - pposix.syslog_open("prosody", module:get_option_string("syslog_facility")); - syslog_opened = true; - end - local syslog = pposix.syslog_log; - return function (name, level, message, ...) - syslog(level, name, format(message, ...)); - end; -end -require "core.loggingmanager".register_sink_type("syslog", syslog_sink_maker); - local daemonize = prosody.opts.daemonize; if daemonize == nil then -- Fall back to config file if not specified on command-line - daemonize = module:get_option("daemonize", prosody.installed); + daemonize = module:get_option_boolean("daemonize", nil); + if daemonize ~= nil then + module:log("warn", "The 'daemonize' option has been deprecated, specify -D or -F on the command line instead."); + -- TODO: Write some docs and include a link in the warning. + end end local function remove_log_sinks() diff --git a/plugins/mod_presence.lua b/plugins/mod_presence.lua index 268a2f0c..d6e2b2b7 100644 --- a/plugins/mod_presence.lua +++ b/plugins/mod_presence.lua @@ -30,6 +30,14 @@ local recalc_resource_map = require "util.presence".recalc_resource_map; local ignore_presence_priority = module:get_option_boolean("ignore_presence_priority", false); +local pre_approval_stream_feature = st.stanza("sub", {xmlns="urn:xmpp:features:pre-approval"}); +module:hook("stream-features", function(event) + local origin, features = event.origin, event.features; + if origin.username then + features:add_child(pre_approval_stream_feature); + end +end); + function handle_normal_presence(origin, stanza) if ignore_presence_priority then local priority = stanza:get_child("priority"); @@ -81,8 +89,14 @@ function handle_normal_presence(origin, stanza) res.presence.attr.to = nil; end end - for jid in pairs(roster[false].pending) do -- resend incoming subscription requests - origin.send(st.presence({type="subscribe", from=jid})); -- TODO add to attribute? Use original? + for jid, pending_request in pairs(roster[false].pending) do -- resend incoming subscription requests + if type(pending_request) == "table" then + local subscribe = st.deserialize(pending_request); + subscribe.attr.type, subscribe.attr.from = "subscribe", jid; + origin.send(subscribe); + else + origin.send(st.presence({type="subscribe", from=jid})); + end end local request = st.presence({type="subscribe", from=origin.username.."@"..origin.host}); for jid, item in pairs(roster) do -- resend outgoing subscription requests @@ -175,8 +189,10 @@ function handle_outbound_presence_subscriptions_and_probes(origin, stanza, from_ if rostermanager.subscribed(node, host, to_bare) then rostermanager.roster_push(node, host, to_bare); end - core_post_stanza(origin, stanza); - send_presence_of_available_resources(node, host, to_bare, origin); + if rostermanager.is_contact_subscribed(node, host, to_bare) then + core_post_stanza(origin, stanza); + send_presence_of_available_resources(node, host, to_bare, origin); + end if rostermanager.is_user_subscribed(node, host, to_bare) then core_post_stanza(origin, st.presence({ type = "probe", from = from_bare, to = to_bare })); end @@ -184,6 +200,8 @@ function handle_outbound_presence_subscriptions_and_probes(origin, stanza, from_ -- 1. send unavailable -- 2. route stanza -- 3. roster push (subscription = from or both) + -- luacheck: ignore 211/pending_in + -- Is pending_in meant to be used? local success, pending_in, subscribed = rostermanager.unsubscribed(node, host, to_bare); if success then if subscribed then @@ -223,10 +241,16 @@ function handle_inbound_presence_subscriptions_and_probes(origin, stanza, from_b if 0 == send_presence_of_available_resources(node, host, from_bare, origin) then core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unavailable"}), true); -- TODO send last activity end + elseif rostermanager.is_contact_preapproved(node, host, from_bare) then + if not rostermanager.is_contact_pending_in(node, host, from_bare) then + if rostermanager.set_contact_pending_in(node, host, from_bare, stanza) then + core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="subscribed"}), true); + end -- TODO else return error, unable to save + end else core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unavailable"}), true); -- acknowledging receipt if not rostermanager.is_contact_pending_in(node, host, from_bare) then - if rostermanager.set_contact_pending_in(node, host, from_bare) then + if rostermanager.set_contact_pending_in(node, host, from_bare, stanza) then sessionmanager.send_to_available_resources(node, host, stanza); end -- TODO else return error, unable to save end diff --git a/plugins/mod_proxy65.lua b/plugins/mod_proxy65.lua index 00833772..29c821e2 100644 --- a/plugins/mod_proxy65.lua +++ b/plugins/mod_proxy65.lua @@ -117,7 +117,7 @@ function module.add_host(module) if jid_compare(jid, acl) then allow = true; break; end end if allow then break; end - module:log("warn", "Denying use of proxy for %s", tostring(stanza.attr.from)); + module:log("warn", "Denying use of proxy for %s", stanza.attr.from); origin.send(st.error_reply(stanza, "auth", "forbidden")); return true; end diff --git a/plugins/mod_pubsub/mod_pubsub.lua b/plugins/mod_pubsub/mod_pubsub.lua index 4276bc5e..4f901ba4 100644 --- a/plugins/mod_pubsub/mod_pubsub.lua +++ b/plugins/mod_pubsub/mod_pubsub.lua @@ -75,14 +75,13 @@ function simple_broadcast(kind, node, jids, item, actor, node_obj) local msg_type = node_obj and node_obj.config.message_type or "headline"; local message = st.message({ from = module.host, type = msg_type, id = id }) :tag("event", { xmlns = xmlns_pubsub_event }) - :tag(kind, { node = node }) + :tag(kind, { node = node }); if item then message:add_child(item); end local summary; - -- Compose a sensible textual representation of at least Atom payloads if item and item.tags[1] then local payload = item.tags[1]; summary = module:fire_event("pubsub-summary/"..payload.attr.xmlns, { @@ -101,11 +100,12 @@ function simple_broadcast(kind, node, jids, item, actor, node_obj) end local max_max_items = module:get_option_number("pubsub_max_items", 256); -function check_node_config(node, actor, new_config) -- luacheck: ignore 212/actor 212/node +function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node 212/actor if (new_config["max_items"] or 1) > max_max_items then return false; end - if new_config["access_model"] ~= "whitelist" and new_config["access_model"] ~= "open" then + if new_config["access_model"] ~= "whitelist" + and new_config["access_model"] ~= "open" then return false; end return true; @@ -115,6 +115,7 @@ function is_item_stanza(item) return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item" and #item.tags == 1; end +-- Compose a textual representation of Atom payloads module:hook("pubsub-summary/http://www.w3.org/2005/Atom", function (event) local payload = event.payload; local title = payload:get_child_text("title"); diff --git a/plugins/mod_pubsub/pubsub.lib.lua b/plugins/mod_pubsub/pubsub.lib.lua index 50ef7ddf..0938dbbc 100644 --- a/plugins/mod_pubsub/pubsub.lib.lua +++ b/plugins/mod_pubsub/pubsub.lib.lua @@ -7,6 +7,7 @@ local st = require "util.stanza"; local it = require "util.iterators"; local uuid_generate = require "util.uuid".generate; local dataform = require"util.dataforms".new; +local errors = require "util.error"; local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; local xmlns_pubsub_errors = "http://jabber.org/protocol/pubsub#errors"; @@ -34,6 +35,9 @@ local pubsub_errors = { }; local function pubsub_error_reply(stanza, error) local e = pubsub_errors[error]; + if not e and errors.is_err(error) then + e = { error.type, error.condition, error.text, error.pubsub_condition }; + end local reply = st.error_reply(stanza, t_unpack(e, 1, 3)); if e[4] then reply:tag(e[4], { xmlns = xmlns_pubsub_errors }):up(); @@ -185,6 +189,14 @@ local node_metadata_form = dataform { type = "text-single"; name = "pubsub#type"; }; + { + type = "text-single"; + name = "pubsub#access_model"; + }; + { + type = "text-single"; + name = "pubsub#publish_model"; + }; }; local service_method_feature_map = { @@ -258,6 +270,8 @@ function _M.handle_disco_info_node(event, service) ["pubsub#title"] = node_obj.config.title; ["pubsub#description"] = node_obj.config.description; ["pubsub#type"] = node_obj.config.payload_type; + ["pubsub#access_model"] = node_obj.config.access_model; + ["pubsub#publish_model"] = node_obj.config.publish_model; }, "result")); end end @@ -318,14 +332,9 @@ function handlers.get_items(origin, stanza, items, service) for _, id in ipairs(results) do data:add_child(results[id]); end - local reply; - if data then - reply = st.reply(stanza) - :tag("pubsub", { xmlns = xmlns_pubsub }) - :add_child(data); - else - reply = pubsub_error_reply(stanza, "item-not-found"); - end + local reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :add_child(data); origin.send(reply); return true; end @@ -633,14 +642,13 @@ function handlers.set_retract(origin, stanza, retract, service) end function handlers.owner_set_purge(origin, stanza, purge, service) - local node, notify = purge.attr.node, purge.attr.notify; - notify = (notify == "1") or (notify == "true"); + local node = purge.attr.node; local reply; if not node then origin.send(pubsub_error_reply(stanza, "nodeid-required")); return true; end - local ok, ret = service:purge(node, stanza.attr.from, notify); + local ok, ret = service:purge(node, stanza.attr.from, true); if ok then reply = st.reply(stanza); else @@ -802,6 +810,7 @@ local function archive_itemstore(archive, config, user, node) end module:log("debug", "Listed items %s", data); return it.reverse(function() + -- luacheck: ignore 211/when local id, payload, when, publisher = data(); if id == nil then return; diff --git a/plugins/mod_register_ibr.lua b/plugins/mod_register_ibr.lua index e04e6ecd..e79fc763 100644 --- a/plugins/mod_register_ibr.lua +++ b/plugins/mod_register_ibr.lua @@ -155,7 +155,7 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event) return true; end - local username, password = nodeprep(data.username), data.password; + local username, password = nodeprep(data.username, true), data.password; data.username, data.password = nil, nil; local host = module.host; if not username or username == "" then @@ -167,8 +167,16 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event) local user = { username = username, password = password, host = host, additional = data, ip = session.ip, session = session, allowed = true } module:fire_event("user-registering", user); if not user.allowed then - log("debug", "Registration disallowed by module: %s", user.reason or "no reason given"); - session.send(st.error_reply(stanza, "modify", "not-acceptable", user.reason)); + local error_type, error_condition, reason; + local err = user.error; + if err then + error_type, error_condition, reason = err.type, err.condition, err.text; + else + -- COMPAT pre-util.error + error_type, error_condition, reason = user.error_type, user.error_condition, user.reason; + end + log("debug", "Registration disallowed by module: %s", reason or "no reason given"); + session.send(st.error_reply(stanza, error_type or "modify", error_condition or "not-acceptable", reason)); return true; end @@ -178,14 +186,13 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event) return true; end - -- TODO unable to write file, file may be locked, etc, what's the correct error? - local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk."); - if usermanager_create_user(username, password, host) then + local created, err = usermanager_create_user(username, password, host); + if created then data.registered = os.time(); if not account_details:set(username, data) then log("debug", "Could not store extra details"); usermanager_delete_user(username, host); - session.send(error_reply); + session.send(st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk.")); return true; end session.send(st.reply(stanza)); -- user created! @@ -194,8 +201,8 @@ module:hook("stanza/iq/jabber:iq:register:query", function(event) username = username, host = host, source = "mod_register", session = session }); else - log("debug", "Could not create user"); - session.send(error_reply); + log("debug", "Could not create user", err); + session.send(st.error_reply(stanza, "cancel", "feature-not-implemented", err)); end return true; end); diff --git a/plugins/mod_register_limits.lua b/plugins/mod_register_limits.lua index 736282a5..7c80f18b 100644 --- a/plugins/mod_register_limits.lua +++ b/plugins/mod_register_limits.lua @@ -13,6 +13,7 @@ local ip_util = require "util.ip"; local new_ip = ip_util.new_ip; local match_ip = ip_util.match; local parse_cidr = ip_util.parse_cidr; +local errors = require "util.error"; local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations"); local whitelist_only = module:get_option_boolean("whitelist_registration_only"); @@ -54,6 +55,24 @@ local function ip_in_set(set, ip) return false; end +local err_registry = { + blacklisted = { + text = "Your IP address is blacklisted"; + type = "auth"; + condition = "forbidden"; + }; + not_whitelisted = { + text = "Your IP address is not whitelisted"; + type = "auth"; + condition = "forbidden"; + }; + throttled = { + text = "Too many registrations from this IP address recently"; + type = "wait"; + condition = "policy-violation"; + }; +} + module:hook("user-registering", function (event) local session = event.session; local ip = event.ip or session and session.ip; @@ -63,16 +82,22 @@ module:hook("user-registering", function (event) elseif ip_in_set(blacklisted_ips, ip) then log("debug", "Registration disallowed by blacklist"); event.allowed = false; - event.reason = "Your IP address is blacklisted"; + event.error = errors.new("blacklisted", event, err_registry); elseif (whitelist_only and not ip_in_set(whitelisted_ips, ip)) then log("debug", "Registration disallowed by whitelist"); event.allowed = false; - event.reason = "Your IP address is not whitelisted"; + event.error = errors.new("not_whitelisted", event, err_registry); elseif throttle_max and not ip_in_set(whitelisted_ips, ip) then if not check_throttle(ip) then log("debug", "Registrations over limit for ip %s", ip or "?"); event.allowed = false; - event.reason = "Too many registrations from this IP address recently"; + event.error = errors.new("throttled", event, err_registry); end end + if event.error then + -- COMPAT pre-util.error + event.reason = event.error.text; + event.error_type = event.error.type; + event.error_condition = event.error.condition; + end end); diff --git a/plugins/mod_s2s/mod_s2s.lua b/plugins/mod_s2s/mod_s2s.lua index aae37b7f..8feb6403 100644 --- a/plugins/mod_s2s/mod_s2s.lua +++ b/plugins/mod_s2s/mod_s2s.lua @@ -27,8 +27,10 @@ local s2s_destroy_session = require "core.s2smanager".destroy_session; local uuid_gen = require "util.uuid".generate; local fire_global_event = prosody.events.fire_event; local runner = require "util.async".runner; - -local s2sout = module:require("s2sout"); +local connect = require "net.connect".connect; +local service = require "net.resolvers.service"; +local errors = require "util.error"; +local set = require "util.set"; local connect_timeout = module:get_option_number("s2s_timeout", 90); local stream_close_timeout = module:get_option_number("s2s_close_timeout", 5); @@ -45,8 +47,16 @@ local sessions = module:shared("sessions"); local runner_callbacks = {}; +local listener = {}; + local log = module._log; +local s2s_service_options = { + default_port = 5269; + use_ipv4 = module:get_option_boolean("use_ipv4", true); + use_ipv6 = module:get_option_boolean("use_ipv6", true); +}; + module:hook("stats-update", function () local count = 0; local ipv6 = 0; @@ -77,15 +87,28 @@ local function bounce_sendq(session, reason) (session.log or log)("error", "Attempting to close the dummy origin of s2s error replies, please report this! Traceback: %s", traceback()); end; }; + -- FIXME Allow for more specific error conditions + -- TODO use util.error ? + local error_type = "cancel"; + local condition = "remote-server-not-found"; + local reason_text; + if session.had_stream then -- set when a stream is opened by the remote + error_type, condition = "wait", "remote-server-timeout"; + end + if errors.is_err(reason) then + error_type, condition, reason_text = reason.type, reason.condition, reason.text; + elseif type(reason) == "string" then + reason_text = reason; + end for i, data in ipairs(sendq) do local reply = data[2]; if reply and not(reply.attr.xmlns) and bouncy_stanzas[reply.name] then reply.attr.type = "error"; - reply:tag("error", {type = "cancel", by = session.from_host}) - :tag("remote-server-not-found", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up(); - if reason then + reply:tag("error", {type = error_type, by = session.from_host}) + :tag(condition, {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up(); + if reason_text then reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}) - :text("Server-to-server connection failed: "..reason):up(); + :text("Server-to-server connection failed: "..reason_text):up(); end core_process_stanza(dummy, reply); end @@ -106,38 +129,33 @@ function route_to_existing_session(event) return false; end local host = hosts[from_host].s2sout[to_host]; - if host then - -- We have a connection to this host already - if host.type == "s2sout_unauthed" and (stanza.name ~= "db:verify" or not host.dialback_key) then - (host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host); - - -- Queue stanza until we are able to send it - local queued_item = { - tostring(stanza), - stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza); - }; - if host.sendq then - t_insert(host.sendq, queued_item); - else - -- luacheck: ignore 122 - host.sendq = { queued_item }; - end - host.log("debug", "stanza [%s] queued ", stanza.name); - return true; - elseif host.type == "local" or host.type == "component" then - log("error", "Trying to send a stanza to ourselves??") - log("error", "Traceback: %s", traceback()); - log("error", "Stanza: %s", tostring(stanza)); - return false; + if not host then return end + + -- We have a connection to this host already + if host.type == "s2sout_unauthed" and (stanza.name ~= "db:verify" or not host.dialback_key) then + (host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host); + + -- Queue stanza until we are able to send it + local queued_item = { + tostring(stanza), + stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza); + }; + if host.sendq then + t_insert(host.sendq, queued_item); else - -- FIXME - if host.from_host ~= from_host then - log("error", "WARNING! This might, possibly, be a bug, but it might not..."); - log("error", "We are going to send from %s instead of %s", host.from_host, from_host); - end - if host.sends2s(stanza) then - return true; - end + -- luacheck: ignore 122 + host.sendq = { queued_item }; + end + host.log("debug", "stanza [%s] queued ", stanza.name); + return true; + elseif host.type == "local" or host.type == "component" then + log("error", "Trying to send a stanza to ourselves??") + log("error", "Traceback: %s", traceback()); + log("error", "Stanza: %s", stanza); + return false; + else + if host.sends2s(stanza) then + return true; end end end @@ -147,17 +165,13 @@ function route_to_new_session(event) local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza; log("debug", "opening a new outgoing connection for this stanza"); local host_session = s2s_new_outgoing(from_host, to_host); + host_session.version = 1; -- Store in buffer host_session.bounce_sendq = bounce_sendq; host_session.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} }; - log("debug", "stanza [%s] queued until connection complete", tostring(stanza.name)); - s2sout.initiate_connection(host_session); - if (not host_session.connecting) and (not host_session.conn) then - log("warn", "Connection to %s failed already, destroying session...", to_host); - s2s_destroy_session(host_session, "Connection failed"); - return false; - end + log("debug", "stanza [%s] queued until connection complete", stanza.name); + connect(service.new(to_host, "xmpp-server", "tcp", s2s_service_options), listener, nil, { session = host_session }); return true; end @@ -182,10 +196,20 @@ function module.add_host(module) -- so the stream is ready for stanzas. RFC 6120 Section 4.3 mark_connected(session); return true; + elseif require_encryption and not session.secure then + session.log("warn", "Encrypted server-to-server communication is required but was not offered by %s", session.to_host); + session:close({ + condition = "policy-violation", + text = "Encrypted server-to-server communication is required but was not offered", + }, nil, "Could not establish encrypted connection to remote server"); + return true; elseif not session.dialback_verifying then session.log("warn", "No SASL EXTERNAL offer and Dialback doesn't seem to be enabled, giving up"); - session:close(); - return false; + session:close({ + condition = "unsupported-feature", + text = "No viable authentication method offered", + }, nil, "No viable authentication method offered by remote server"); + return true; end end, -1); end @@ -203,7 +227,18 @@ function mark_connected(session) if session.type == "s2sout" then fire_global_event("s2sout-established", event_data); hosts[from].events.fire_event("s2sout-established", event_data); + + if session.incoming then + session.send = function(stanza) + return hosts[from].events.fire_event("route/remote", { from_host = from, to_host = to, stanza = stanza }); + end; + end + else + if session.outgoing and not hosts[to].s2sout[from] then + session.log("debug", "Setting up to handle route from %s to %s", to, from); + hosts[to].s2sout[from] = session; -- luacheck: ignore 122 + end local host_session = hosts[to]; session.send = function(stanza) return host_session.events.fire_event("route/remote", { from_host = to, to_host = from, stanza = stanza }); @@ -223,13 +258,6 @@ function mark_connected(session) end session.sendq = nil; end - - if session.resolver then - session.resolver._resolver:closeall() - end - session.resolver = nil; - session.ip_hosts = nil; - session.srv_hosts = nil; end end @@ -241,7 +269,7 @@ function make_authenticated(event) condition = "policy-violation", text = "Encrypted server-to-server communication is required but was not " ..((session.direction == "outgoing" and "offered") or "used") - }); + }, nil, "Could not establish encrypted connection to remote server"); end end if hosts[host] then @@ -251,15 +279,13 @@ function make_authenticated(event) session.type = "s2sout"; elseif session.type == "s2sin_unauthed" then session.type = "s2sin"; - if host then - if not session.hosts[host] then session.hosts[host] = {}; end - session.hosts[host].authed = true; - end - elseif session.type == "s2sin" and host then + elseif session.type ~= "s2sin" and session.type ~= "s2sout" then + return false; + end + + if session.incoming and host then if not session.hosts[host] then session.hosts[host] = {}; end session.hosts[host].authed = true; - else - return false; end session.log("debug", "connection %s->%s is now authenticated for %s", session.from_host, session.to_host, host); @@ -301,6 +327,7 @@ end function stream_callbacks._streamopened(session, attr) session.version = tonumber(attr.version) or 0; + session.had_stream = true; -- Had a stream opened at least once -- TODO: Rename session.secure to session.encrypted if session.secure == false then @@ -314,7 +341,6 @@ function stream_callbacks._streamopened(session, attr) session.compressed = info.compression; else (session.log or log)("info", "Stream encrypted"); - session.compressed = sock.compression and sock:compression(); --COMPAT mw/luasec-hg end end @@ -322,7 +348,9 @@ function stream_callbacks._streamopened(session, attr) -- Send a reply stream header -- Validate to/from - local to, from = nameprep(attr.to), nameprep(attr.from); + local to, from = attr.to, attr.from; + if to then to = nameprep(attr.to); end + if from then from = nameprep(attr.from); end if not to and attr.to then -- COMPAT: Some servers do not reliably set 'to' (especially on stream restarts) session:close({ condition = "improper-addressing", text = "Invalid 'to' address" }); return; @@ -416,20 +444,6 @@ function stream_callbacks._streamopened(session, attr) end end - -- Send unauthed buffer - -- (stanzas which are fine to send before dialback) - -- Note that this is *not* the stanza queue (which - -- we can only send if auth succeeds) :) - local send_buffer = session.send_buffer; - if send_buffer and #send_buffer > 0 then - log("debug", "Sending s2s send_buffer now..."); - for i, data in ipairs(send_buffer) do - session.sends2s(tostring(data)); - send_buffer[i] = nil; - end - end - session.send_buffer = nil; - -- If server is pre-1.0, don't wait for features, just do dialback if session.version < 1.0 then if not session.dialback_verifying then @@ -471,11 +485,12 @@ function stream_callbacks.error(session, error, data) end end -local listener = {}; - --- Session methods local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'}; -local function session_close(session, reason, remote_reason) +-- reason: stream error to send to the remote server +-- remote_reason: stream error received from the remote server +-- bounce_reason: stanza error to pass to bounce_sendq because stream- and stanza errors are different +local function session_close(session, reason, remote_reason, bounce_reason) local log = session.log or log; if session.conn then if session.notopen then @@ -486,27 +501,23 @@ local function session_close(session, reason, remote_reason) end end if reason then -- nil == no err, initiated by us, false == initiated by remote + local stream_error; if type(reason) == "string" then -- assume stream error - log("debug", "Disconnecting %s[%s], <stream:error> is: %s", session.host or session.ip or "(unknown host)", session.type, reason); - session.sends2s(st.stanza("stream:error"):tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' })); - elseif type(reason) == "table" then - if reason.condition then - local stanza = st.stanza("stream:error"):tag(reason.condition, stream_xmlns_attr):up(); - if reason.text then - stanza:tag("text", stream_xmlns_attr):text(reason.text):up(); - end - if reason.extra then - stanza:add_child(reason.extra); - end - log("debug", "Disconnecting %s[%s], <stream:error> is: %s", - session.host or session.ip or "(unknown host)", session.type, stanza); - session.sends2s(stanza); - elseif reason.name then -- a stanza - log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s", - session.from_host or "(unknown host)", session.to_host or "(unknown host)", - session.type, reason); - session.sends2s(reason); + stream_error = st.stanza("stream:error"):tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' }); + elseif type(reason) == "table" and not st.is_stanza(reason) then + stream_error = st.stanza("stream:error"):tag(reason.condition or "undefined-condition", stream_xmlns_attr):up(); + if reason.text then + stream_error:tag("text", stream_xmlns_attr):text(reason.text):up(); end + if reason.extra then + stream_error:add_child(reason.extra); + end + end + if st.is_stanza(stream_error) then + -- to and from are never unknown on outgoing connections + log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s", + session.from_host or "(unknown host)" or session.ip, session.to_host or "(unknown host)", session.type, reason); + session.sends2s(stream_error); end end @@ -521,16 +532,16 @@ local function session_close(session, reason, remote_reason) -- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote local conn = session.conn; - if reason == nil and not session.notopen and session.type == "s2sin" then + if reason == nil and not session.notopen and session.incoming then add_task(stream_close_timeout, function () if not session.destroyed then session.log("warn", "Failed to receive a stream close response, closing connection anyway..."); - s2s_destroy_session(session, reason); + s2s_destroy_session(session, reason, bounce_reason); conn:close(); end end); else - s2s_destroy_session(session, reason); + s2s_destroy_session(session, reason, bounce_reason); conn:close(); -- Close immediately, as this is an outgoing connection or is not authed end end @@ -595,9 +606,8 @@ local function initialize_session(session) if data then local ok, err = stream:feed(data); if ok then return; end - log("warn", "Received invalid XML: %s", data); - log("warn", "Problem was: %s", err); - session:close("not-well-formed"); + log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300)); + session:close("not-well-formed", nil, "Received invalid XML from remote server"); end end @@ -672,11 +682,20 @@ function listener.ondisconnect(conn, err) local session = sessions[conn]; if session then sessions[conn] = nil; + (session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed"); + if session.secure == false and err then + -- TODO util.error-ify this + err = "Error during negotiation of encrypted connection: "..err; + end + s2s_destroy_session(session, err); + end +end + +function listener.onfail(data, err) + local session = data and data.session; + if session then if err and session.direction == "outgoing" and session.notopen then (session.log or log)("debug", "s2s connection attempt failed: %s", err); - if s2sout.attempt_connection(session, err) then - return; -- Session lives for now - end end (session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed"); s2s_destroy_session(session, err); @@ -700,6 +719,34 @@ function listener.ondetach(conn) sessions[conn] = nil; end +function listener.onattach(conn, data) + local session = data and data.session; + if session then + session.conn = conn; + sessions[conn] = session; + initialize_session(session); + end +end + +-- Complete the sentence "Your certificate " with what's wrong +local function friendly_cert_error(session) --> string + if session.cert_chain_status == "invalid" then + if session.cert_chain_errors then + local cert_errors = set.new(session.cert_chain_errors[1]); + if cert_errors:contains("certificate has expired") then + return "has expired"; + elseif cert_errors:contains("self signed certificate") then + return "is self-signed"; + end + end + return "is not trusted"; -- for some other reason + elseif session.cert_identity_status == "invalid" then + return "is not valid for this name"; + end + -- this should normally be unreachable except if no s2s auth module was loaded + return "could not be validated"; +end + function check_auth_policy(event) local host, session = event.host, event.session; local must_secure = secure_auth; @@ -711,20 +758,21 @@ function check_auth_policy(event) end if must_secure and (session.cert_chain_status ~= "valid" or session.cert_identity_status ~= "valid") then - module:log("warn", "Forbidding insecure connection to/from %s", host or session.ip or "(unknown host)"); - if session.direction == "incoming" then - session:close({ condition = "not-authorized", text = "Your server's certificate is invalid, expired, or not trusted by "..session.to_host }); - else -- Close outgoing connections without warning - session:close(false); - end + local reason = friendly_cert_error(session); + session.log("warn", "Forbidding insecure connection to/from %s because its certificate %s", host or session.ip or "(unknown host)", reason); + -- XEP-0178 recommends closing outgoing connections without warning + -- but does not give a rationale for this. + -- In practice most cases are configuration mistakes or forgotten + -- certificate renewals. We think it's better to let the other party + -- know about the problem so that they can fix it. + session:close({ condition = "not-authorized", text = "Your server's certificate "..reason }, + nil, "Remote server's certificate "..reason); return false; end end module:hook("s2s-check-certificate", check_auth_policy, -1); -s2sout.set_listener(listener); - module:hook("server-stopping", function(event) local reason = event.reason; for _, session in pairs(sessions) do @@ -739,7 +787,11 @@ module:provides("net", { listener = listener; default_port = 5269; encryption = "starttls"; + ssl_config = { -- FIXME This is not used atm, see mod_tls + verify = { "peer", "client_once", }; + }; multiplex = { + protocol = "xmpp-server"; pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:server%1.*>"; }; }); diff --git a/plugins/mod_s2s/s2sout.lib.lua b/plugins/mod_s2s/s2sout.lib.lua deleted file mode 100644 index 5f765da8..00000000 --- a/plugins/mod_s2s/s2sout.lib.lua +++ /dev/null @@ -1,349 +0,0 @@ --- Prosody IM --- Copyright (C) 2008-2010 Matthew Wild --- Copyright (C) 2008-2010 Waqas Hussain --- --- This project is MIT/X11 licensed. Please see the --- COPYING file in the source package for more information. --- - ---- Module containing all the logic for connecting to a remote server - --- luacheck: ignore 432/err - -local portmanager = require "core.portmanager"; -local wrapclient = require "net.server".wrapclient; -local initialize_filters = require "util.filters".initialize; -local idna_to_ascii = require "util.encodings".idna.to_ascii; -local new_ip = require "util.ip".new_ip; -local rfc6724_dest = require "util.rfc6724".destination; -local socket = require "socket"; -local adns = require "net.adns"; -local t_insert, t_sort, ipairs = table.insert, table.sort, ipairs; -local local_addresses = require "util.net".local_addresses; - -local s2s_destroy_session = require "core.s2smanager".destroy_session; - -local default_mode = module:get_option("network_default_read_size", 4096); - -local log = module._log; - -local sources = {}; -local has_ipv4, has_ipv6; - -local dns_timeout = module:get_option_number("dns_timeout", 15); -local resolvers = module:get_option_set("s2s_dns_resolvers") - -local s2sout = {}; - -local s2s_listener; - - -function s2sout.set_listener(listener) - s2s_listener = listener; -end - -local function compare_srv_priorities(a,b) - return a.priority < b.priority or (a.priority == b.priority and a.weight > b.weight); -end - -function s2sout.initiate_connection(host_session) - local log = host_session.log or log; - - initialize_filters(host_session); - host_session.version = 1; - - host_session.resolver = adns.resolver(); - host_session.resolver._resolver:settimeout(dns_timeout); - if resolvers then - for resolver in resolvers do - host_session.resolver._resolver:addnameserver(resolver); - end - end - - -- Kick the connection attempting machine into life - if not s2sout.attempt_connection(host_session) then - -- Intentionally not returning here, the - -- session is needed, connected or not - s2s_destroy_session(host_session); - end - - if not host_session.sends2s then - -- A sends2s which buffers data (until the stream is opened) - -- note that data in this buffer will be sent before the stream is authed - -- and will not be ack'd in any way, successful or otherwise - local buffer; - function host_session.sends2s(data) - if not buffer then - buffer = {}; - host_session.send_buffer = buffer; - end - log("debug", "Buffering data on unconnected s2sout to %s", host_session.to_host); - buffer[#buffer+1] = data; - log("debug", "Buffered item %d: %s", #buffer, data); - end - end -end - -function s2sout.attempt_connection(host_session, err) - local to_host = host_session.to_host; - local connect_host, connect_port = to_host and idna_to_ascii(to_host), 5269; - local log = host_session.log or log; - - if not connect_host then - return false; - end - - if not err then -- This is our first attempt - log("debug", "First attempt to connect to %s, starting with SRV lookup...", to_host); - host_session.connecting = true; - host_session.resolver:lookup(function (answer) - local srv_hosts = { answer = answer }; - host_session.srv_hosts = srv_hosts; - host_session.srv_choice = 0; - host_session.connecting = nil; - if answer and #answer > 0 then - log("debug", "%s has SRV records, handling...", to_host); - for _, record in ipairs(answer) do - t_insert(srv_hosts, record.srv); - end - if #srv_hosts == 1 and srv_hosts[1].target == "." then - log("debug", "%s does not provide a XMPP service", to_host); - s2s_destroy_session(host_session, err); -- Nothing to see here - return; - end - t_sort(srv_hosts, compare_srv_priorities); - - local srv_choice = srv_hosts[1]; - host_session.srv_choice = 1; - if srv_choice then - connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port; - log("debug", "Best record found, will connect to %s:%d", connect_host, connect_port); - end - else - log("debug", "%s has no SRV records, falling back to A/AAAA", to_host); - end - -- Try with SRV, or just the plain hostname if no SRV - local ok, err = s2sout.try_connect(host_session, connect_host, connect_port); - if not ok then - if not s2sout.attempt_connection(host_session, err) then - -- No more attempts will be made - s2s_destroy_session(host_session, err); - end - end - end, "_xmpp-server._tcp."..connect_host..".", "SRV"); - - return true; -- Attempt in progress - elseif host_session.ip_hosts then - return s2sout.try_connect(host_session, connect_host, connect_port, err); - elseif host_session.srv_hosts and #host_session.srv_hosts > host_session.srv_choice then -- Not our first attempt, and we also have SRV - host_session.srv_choice = host_session.srv_choice + 1; - local srv_choice = host_session.srv_hosts[host_session.srv_choice]; - connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port; - host_session.log("info", "Connection failed (%s). Attempt #%d: This time to %s:%d", err, host_session.srv_choice, connect_host, connect_port); - else - host_session.log("info", "Failed in all attempts to connect to %s", host_session.to_host); - -- We're out of options - return false; - end - - if not (connect_host and connect_port) then - -- Likely we couldn't resolve DNS - log("warn", "Hmm, we're without a host (%s) and port (%s) to connect to for %s, giving up :(", connect_host, connect_port, to_host); - return false; - end - - return s2sout.try_connect(host_session, connect_host, connect_port); -end - -function s2sout.try_next_ip(host_session) - host_session.connecting = nil; - host_session.ip_choice = host_session.ip_choice + 1; - local ip = host_session.ip_hosts[host_session.ip_choice]; - local ok, err= s2sout.make_connect(host_session, ip.ip, ip.port); - if not ok then - if not s2sout.attempt_connection(host_session, err or "closed") then - err = err and (": "..err) or ""; - s2s_destroy_session(host_session, "Connection failed"..err); - end - end -end - -function s2sout.try_connect(host_session, connect_host, connect_port, err) - host_session.connecting = true; - local log = host_session.log or log; - - if not err then - local IPs = {}; - host_session.ip_hosts = IPs; - -- luacheck: ignore 231/handle4 231/handle6 - local handle4, handle6; - local have_other_result = not(has_ipv4) or not(has_ipv6) or false; - - if has_ipv4 then - handle4 = host_session.resolver:lookup(function (reply, err) - handle4 = nil; - - if reply and reply[#reply] and reply[#reply].a then - for _, ip in ipairs(reply) do - log("debug", "DNS reply for %s gives us %s", connect_host, ip.a); - IPs[#IPs+1] = new_ip(ip.a, "IPv4"); - end - elseif err then - log("debug", "Error in DNS lookup: %s", err); - end - - if have_other_result then - if #IPs > 0 then - rfc6724_dest(host_session.ip_hosts, sources); - for i = 1, #IPs do - IPs[i] = {ip = IPs[i], port = connect_port}; - end - host_session.ip_choice = 0; - s2sout.try_next_ip(host_session); - else - log("debug", "DNS lookup failed to get a response for %s", connect_host); - host_session.ip_hosts = nil; - if not s2sout.attempt_connection(host_session, "name resolution failed") then -- Retry if we can - log("debug", "No other records to try for %s - destroying", host_session.to_host); - err = err and (": "..err) or ""; - s2s_destroy_session(host_session, "DNS resolution failed"..err); -- End of the line, we can't - end - end - else - have_other_result = true; - end - end, connect_host, "A", "IN"); - else - have_other_result = true; - end - - if has_ipv6 then - handle6 = host_session.resolver:lookup(function (reply, err) - handle6 = nil; - - if reply and reply[#reply] and reply[#reply].aaaa then - for _, ip in ipairs(reply) do - log("debug", "DNS reply for %s gives us %s", connect_host, ip.aaaa); - IPs[#IPs+1] = new_ip(ip.aaaa, "IPv6"); - end - elseif err then - log("debug", "Error in DNS lookup: %s", err); - end - - if have_other_result then - if #IPs > 0 then - rfc6724_dest(host_session.ip_hosts, sources); - for i = 1, #IPs do - IPs[i] = {ip = IPs[i], port = connect_port}; - end - host_session.ip_choice = 0; - s2sout.try_next_ip(host_session); - else - log("debug", "DNS lookup failed to get a response for %s", connect_host); - host_session.ip_hosts = nil; - if not s2sout.attempt_connection(host_session, "name resolution failed") then -- Retry if we can - log("debug", "No other records to try for %s - destroying", host_session.to_host); - err = err and (": "..err) or ""; - s2s_destroy_session(host_session, "DNS resolution failed"..err); -- End of the line, we can't - end - end - else - have_other_result = true; - end - end, connect_host, "AAAA", "IN"); - else - have_other_result = true; - end - return true; - elseif host_session.ip_hosts and #host_session.ip_hosts > host_session.ip_choice then -- Not our first attempt, and we also have IPs left to try - s2sout.try_next_ip(host_session); - else - log("debug", "Out of IP addresses, trying next SRV record (if any)"); - host_session.ip_hosts = nil; - if not s2sout.attempt_connection(host_session, "out of IP addresses") then -- Retry if we can - log("debug", "No other records to try for %s - destroying", host_session.to_host); - err = err and (": "..err) or ""; - s2s_destroy_session(host_session, "Connecting failed"..err); -- End of the line, we can't - return false; - end - end - - return true; -end - -function s2sout.make_connect(host_session, connect_host, connect_port) - local log = host_session.log or log; - log("debug", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port); - - -- Reset secure flag in case this is another - -- connection attempt after a failed STARTTLS - host_session.secure = nil; - host_session.encrypted = nil; - - local conn, handler; - local proto = connect_host.proto; - if proto == "IPv4" then - conn, handler = socket.tcp(); - elseif proto == "IPv6" and socket.tcp6 then - conn, handler = socket.tcp6(); - else - handler = "Unsupported protocol: "..tostring(proto); - end - - if not conn then - log("warn", "Failed to create outgoing connection, system error: %s", handler); - return false, handler; - end - - conn:settimeout(0); - local success, err = conn:connect(connect_host.addr, connect_port); - if not success and err ~= "timeout" then - log("warn", "s2s connect() to %s (%s:%d) failed: %s", host_session.to_host, connect_host.addr, connect_port, err); - return false, err; - end - - conn = wrapclient(conn, connect_host.addr, connect_port, s2s_listener, default_mode); - host_session.conn = conn; - - -- Register this outgoing connection so that xmppserver_listener knows about it - -- otherwise it will assume it is a new incoming connection - s2s_listener.register_outgoing(conn, host_session); - - log("debug", "Connection attempt in progress..."); - return true; -end - -module:hook_global("service-added", function (event) - if event.name ~= "s2s" then return end - - local s2s_sources = portmanager.get_active_services():get("s2s"); - if not s2s_sources then - module:log("warn", "s2s not listening on any ports, outgoing connections may fail"); - return; - end - for source, _ in pairs(s2s_sources) do - if source == "*" or source == "0.0.0.0" then - for _, addr in ipairs(local_addresses("ipv4", true)) do - sources[#sources + 1] = new_ip(addr, "IPv4"); - end - elseif source == "::" then - for _, addr in ipairs(local_addresses("ipv6", true)) do - sources[#sources + 1] = new_ip(addr, "IPv6"); - end - else - sources[#sources + 1] = new_ip(source, (source:find(":") and "IPv6") or "IPv4"); - end - end - for i = 1,#sources do - if sources[i].proto == "IPv6" then - has_ipv6 = true; - elseif sources[i].proto == "IPv4" then - has_ipv4 = true; - end - end - if not (has_ipv4 or has_ipv6) then - module:log("warn", "No local IPv4 or IPv6 addresses detected, outgoing connections may fail"); - end -end); - -return s2sout; diff --git a/plugins/mod_s2s_auth_certs.lua b/plugins/mod_s2s_auth_certs.lua index dd0eb3cb..37519aa1 100644 --- a/plugins/mod_s2s_auth_certs.lua +++ b/plugins/mod_s2s_auth_certs.lua @@ -17,9 +17,6 @@ module:hook("s2s-check-certificate", function(event) local chain_valid, errors; if conn.getpeerverification then chain_valid, errors = conn:getpeerverification(); - elseif conn.getpeerchainvalid then -- COMPAT mw/luasec-hg - chain_valid, errors = conn:getpeerchainvalid(); - errors = (not chain_valid) and { { errors } } or nil; else chain_valid, errors = false, { { "Chain verification not supported by this version of LuaSec" } }; end @@ -30,6 +27,7 @@ module:hook("s2s-check-certificate", function(event) log("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", ")) end session.cert_chain_status = "invalid"; + session.cert_chain_errors = errors; else log("debug", "certificate chain validation result: valid"); session.cert_chain_status = "valid"; diff --git a/plugins/mod_s2s_bidi.lua b/plugins/mod_s2s_bidi.lua new file mode 100644 index 00000000..28e047de --- /dev/null +++ b/plugins/mod_s2s_bidi.lua @@ -0,0 +1,40 @@ +-- Prosody IM +-- Copyright (C) 2019 Kim Alvefur +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; + +local xmlns_bidi_feature = "urn:xmpp:features:bidi" +local xmlns_bidi = "urn:xmpp:bidi"; + +local require_encryption = module:get_option_boolean("s2s_require_encryption", false); + +module:hook("s2s-stream-features", function(event) + local origin, features = event.origin, event.features; + if origin.type == "s2sin_unauthed" and (not require_encryption or origin.secure) then + features:tag("bidi", { xmlns = xmlns_bidi_feature }):up(); + end +end); + +module:hook_tag("http://etherx.jabber.org/streams", "features", function (session, stanza) + if session.type == "s2sout_unauthed" and (not require_encryption or session.secure) then + local bidi = stanza:get_child("bidi", xmlns_bidi_feature); + if bidi then + session.incoming = true; + session.log("debug", "Requesting bidirectional stream"); + session.sends2s(st.stanza("bidi", { xmlns = xmlns_bidi })); + end + end +end, 200); + +module:hook_tag("urn:xmpp:bidi", "bidi", function(session) + if session.type == "s2sin_unauthed" and (not require_encryption or session.secure) then + session.log("debug", "Requested bidirectional stream"); + session.outgoing = true; + return true; + end +end); + diff --git a/plugins/mod_saslauth.lua b/plugins/mod_saslauth.lua index fba84ef8..2a5edcb2 100644 --- a/plugins/mod_saslauth.lua +++ b/plugins/mod_saslauth.lua @@ -12,9 +12,10 @@ local st = require "util.stanza"; local sm_bind_resource = require "core.sessionmanager".bind_resource; local sm_make_authenticated = require "core.sessionmanager".make_authenticated; local base64 = require "util.encodings".base64; +local set = require "util.set"; +local errors = require "util.error"; local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler; -local tostring = tostring; local secure_auth_only = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", false)); local allow_unencrypted_plain_auth = module:get_option_boolean("allow_unencrypted_plain_auth", false) @@ -48,7 +49,7 @@ local function handle_status(session, status, ret, err_msg) module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg }); session.sasl_handler = session.sasl_handler:clean_clone(); elseif status == "success" then - local ok, err = sm_make_authenticated(session, session.sasl_handler.username); + local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.scope); if ok then module:fire_event("authentication-success", { session = session }); session.sasl_handler = nil; @@ -67,7 +68,6 @@ local function sasl_process_cdata(session, stanza) local text = stanza[1]; if text then text = base64.decode(text); - --log("debug", "AUTH: %s", text:gsub("[%z\001-\008\011\012\014-\031]", " ")); if not text then session.sasl_handler = nil; session.send(build_reply("failure", "incorrect-encoding")); @@ -77,7 +77,6 @@ local function sasl_process_cdata(session, stanza) local status, ret, err_msg = session.sasl_handler:process(text); status, ret, err_msg = handle_status(session, status, ret, err_msg); local s = build_reply(status, ret, err_msg); - log("debug", "sasl reply: %s", tostring(s)); session.send(s); return true; end @@ -104,18 +103,27 @@ module:hook_tag(xmlns_sasl, "failure", function (session, stanza) break; end end - if text and condition then - condition = condition .. ": " .. text; - end - module:log("info", "SASL EXTERNAL with %s failed: %s", session.to_host, condition); + local err = errors.new({ + -- TODO type = what? + text = text, + condition = condition, + }, { + session = session, + stanza = stanza, + }); + + module:log("info", "SASL EXTERNAL with %s failed: %s", session.to_host, err); session.external_auth = "failed" - session.external_auth_failure_reason = condition; + session.external_auth_failure_reason = err; end, 500) module:hook_tag(xmlns_sasl, "failure", function (session, stanza) -- luacheck: ignore 212/stanza session.log("debug", "No fallback from SASL EXTERNAL failure, giving up"); - session:close(nil, session.external_auth_failure_reason); + session:close(nil, session.external_auth_failure_reason, errors.new({ + type = "wait", condition = "remote-server-timeout", + text = "Could not authenticate to remote server", + }, { session = session, sasl_failure = session.external_auth_failure_reason, })); return true; end, 90) @@ -248,37 +256,72 @@ module:hook("stream-features", function(event) local sasl_handler = usermanager_get_sasl_handler(module.host, origin) origin.sasl_handler = sasl_handler; if origin.encrypted then - -- check wether LuaSec has the nifty binding to the function needed for tls-unique + -- check whether LuaSec has the nifty binding to the function needed for tls-unique -- FIXME: would be nice to have this check only once and not for every socket if sasl_handler.add_cb_handler then local socket = origin.conn:socket(); if socket.getpeerfinished then + log("debug", "Channel binding 'tls-unique' supported"); sasl_handler:add_cb_handler("tls-unique", tls_unique); + else + log("debug", "Channel binding 'tls-unique' not supported (by LuaSec?)"); end sasl_handler["userdata"] = { ["tls-unique"] = socket; }; + else + log("debug", "Channel binding not supported by SASL handler"); end end local mechanisms = st.stanza("mechanisms", mechanisms_attr); local sasl_mechanisms = sasl_handler:mechanisms() + local available_mechanisms = set.new(); for mechanism in pairs(sasl_mechanisms) do - if disabled_mechanisms:contains(mechanism) then - log("debug", "Not offering disabled mechanism %s", mechanism); - elseif not origin.secure and insecure_mechanisms:contains(mechanism) then - log("debug", "Not offering mechanism %s on insecure connection", mechanism); - else - log("debug", "Offering mechanism %s", mechanism); + available_mechanisms:add(mechanism); + end + log("debug", "SASL mechanisms supported by handler: %s", available_mechanisms); + + local usable_mechanisms = available_mechanisms - disabled_mechanisms; + + local available_disabled = set.intersection(available_mechanisms, disabled_mechanisms); + if not available_disabled:empty() then + log("debug", "Not offering disabled mechanisms: %s", available_disabled); + end + + local available_insecure = set.intersection(available_mechanisms, insecure_mechanisms); + if not origin.secure and not available_insecure:empty() then + log("debug", "Session is not secure, not offering insecure mechanisms: %s", available_insecure); + usable_mechanisms = usable_mechanisms - insecure_mechanisms; + end + + if not usable_mechanisms:empty() then + log("debug", "Offering usable mechanisms: %s", usable_mechanisms); + for mechanism in usable_mechanisms do mechanisms:tag("mechanism"):text(mechanism):up(); end - end - if mechanisms[1] then features:add_child(mechanisms); - elseif not next(sasl_mechanisms) then - log("warn", "No available SASL mechanisms, verify that the configured authentication module is working"); - else - log("warn", "All available authentication mechanisms are either disabled or not suitable for an insecure connection"); + return; + end + + local authmod = module:get_option_string("authentication", "internal_plain"); + if available_mechanisms:empty() then + log("warn", "No available SASL mechanisms, verify that the configured authentication module '%s' is loaded and configured correctly", authmod); + return; end + + if not origin.secure and not available_insecure:empty() then + if not available_disabled:empty() then + log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s) or disabled (%s)", + authmod, available_insecure, available_disabled); + else + log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s)", + authmod, available_insecure); + end + elseif not available_disabled:empty() then + log("warn", "All SASL mechanisms provided by authentication module '%s' are disabled (%s)", + authmod, available_disabled); + end + else features:tag("bind", bind_attr):tag("required"):up():up(); features:tag("session", xmpp_session_attr):tag("optional"):up():up(); diff --git a/plugins/mod_scansion_record.lua b/plugins/mod_scansion_record.lua index 8d772b4e..0e109a0d 100644 --- a/plugins/mod_scansion_record.lua +++ b/plugins/mod_scansion_record.lua @@ -37,8 +37,7 @@ local function record_event(session, event) end local function record_stanza(stanza, session, verb) - local flattened = tostring(stanza):gsub("><", ">\n\t<"); - -- TODO Proper prettyprinting with indentation + local flattened = tostring(stanza:indent(2, "\t")); record(session.scansion_id.." "..verb..":\n\t"..flattened.."\n\n"); end diff --git a/plugins/mod_stanza_debug.lua b/plugins/mod_stanza_debug.lua index 6dedb6f7..af98670c 100644 --- a/plugins/mod_stanza_debug.lua +++ b/plugins/mod_stanza_debug.lua @@ -1,18 +1,17 @@ module:set_global(); -local tostring = tostring; local filters = require "util.filters"; local function log_send(t, session) if t and t ~= "" and t ~= " " then - session.log("debug", "SEND: %s", tostring(t)); + session.log("debug", "SEND: %s", t); end return t; end local function log_recv(t, session) if t and t ~= "" and t ~= " " then - session.log("debug", "RECV: %s", tostring(t)); + session.log("debug", "RECV: %s", t); end return t; end diff --git a/plugins/mod_storage_internal.lua b/plugins/mod_storage_internal.lua index 42b451bd..c8b902cf 100644 --- a/plugins/mod_storage_internal.lua +++ b/plugins/mod_storage_internal.lua @@ -1,12 +1,17 @@ +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 host = module.host; +local archive_item_limit = module:get_option_number("storage_archive_item_limit", 10000); +local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000)); + local driver = {}; function driver:open(store, typ) @@ -43,6 +48,12 @@ end local archive = {}; driver.archive = { __index = archive }; +archive.caps = { + total = true; + quota = archive_item_limit; + truncate = true; +}; + function archive:append(username, key, value, when, with) when = when or now(); if not st.is_stanza(value) then @@ -54,28 +65,57 @@ function archive:append(username, key, value, when, with) value.attr.stamp = datetime.datetime(when); value.attr.stamp_legacy = datetime.legacy(when); + local cache_key = jid_join(username, host, self.store); + local item_count = archive_item_count_cache:get(cache_key); + if key then local items, err = datamanager.list_load(username, host, self.store); if not items and err then return items, err; end + + -- Check the quota + item_count = items and #items or 0; + archive_item_count_cache:set(cache_key, item_count); + if item_count >= archive_item_limit then + module:log("debug", "%s reached or over quota, not adding to store", username); + return nil, "quota-limit"; + end + if items then + -- Filter out any item with the same key as the one being added items = array(items); items:filter(function (item) return item.key ~= key; end); + value.key = key; items:push(value); local ok, err = datamanager.list_store(username, host, self.store, items); if not ok then return ok, err; end + archive_item_count_cache:set(cache_key, #items); return key; end else + if not item_count then -- Item count not cached? + -- We need to load the list to get the number of items currently stored + local items, err = datamanager.list_load(username, host, self.store); + if not items and err then return items, err; end + item_count = items and #items or 0; + archive_item_count_cache:set(cache_key, item_count); + end + if item_count >= archive_item_limit then + module:log("debug", "%s reached or over quota, not adding to store", username); + return nil, "quota-limit"; + end key = id(); end + module:log("debug", "%s has %d items out of %d limit in store %s", username, item_count, archive_item_limit, self.store); + value.key = key; local ok, err = datamanager.list_append(username, host, self.store, value); if not ok then return ok, err; end + archive_item_count_cache:set(cache_key, item_count+1); return key; end @@ -84,11 +124,17 @@ function archive:find(username, query) if not items then if err then return items, err; - else - return function () end, 0; + elseif query then + if query.before or query.after then + return nil, "item-not-found"; + end + if query.total then + return function () end, 0; + end end + return function () end; end - local count = #items; + local count = nil; local i = 0; if query then items = array(items); @@ -112,24 +158,36 @@ function archive:find(username, query) return item.when <= query["end"]; end); end - count = #items; + if query.total then + count = #items; + end if query.reverse then items:reverse(); if query.before then - for j = 1, count do + local found = false; + for j = 1, #items do if (items[j].key or tostring(j)) == query.before then + found = true; i = j; break; end end + if not found then + return nil, "item-not-found"; + end end elseif query.after then - for j = 1, count do + local found = false; + for j = 1, #items do if (items[j].key or tostring(j)) == query.after then + found = true; i = j; break; end end + if not found then + return nil, "item-not-found"; + end end if query.limit and #items - i > query.limit then items[i+query.limit+1] = nil; @@ -156,8 +214,37 @@ function archive:dates(username) return array(items):pluck("when"):map(datetime.date):unique(); end +function archive:summary(username, query) + local iter, err = self:find(username, query) + if not iter then return iter, err; end + local counts = {}; + local earliest = {}; + local latest = {}; + local body = {}; + for _, stanza, when, with in iter do + counts[with] = (counts[with] or 0) + 1; + if earliest[with] == nil then + earliest[with] = when; + end + latest[with] = when; + body[with] = stanza:get_child_text("body") or body[with]; + end + return { + counts = counts; + earliest = earliest; + latest = latest; + body = body; + }; +end + +function archive:users() + return datamanager.users(host, self.store, "list"); +end + function archive:delete(username, query) + local cache_key = jid_join(username, host, self.store); if not query or next(query) == nil then + archive_item_count_cache:set(cache_key, nil); return datamanager.list_store(username, host, self.store, nil); end local items, err = datamanager.list_load(username, host, self.store); @@ -165,6 +252,7 @@ function archive:delete(username, query) if err then return items, err; end + archive_item_count_cache:set(cache_key, 0); -- Store is empty return 0; end @@ -214,6 +302,7 @@ function archive:delete(username, query) end local ok, err = datamanager.list_store(username, host, self.store, items); if not ok then return ok, err; end + archive_item_count_cache:set(cache_key, #items); return count; end diff --git a/plugins/mod_storage_memory.lua b/plugins/mod_storage_memory.lua index 745e394b..8beb8c01 100644 --- a/plugins/mod_storage_memory.lua +++ b/plugins/mod_storage_memory.lua @@ -8,6 +8,8 @@ local new_id = require "util.id".medium; local auto_purge_enabled = module:get_option_boolean("storage_memory_temporary", false); local auto_purge_stores = module:get_option_set("storage_memory_temporary_stores", {}); +local archive_item_limit = module:get_option_number("storage_archive_item_limit", 1000); + local memory = setmetatable({}, { __index = function(t, k) local store = module:shared(k) @@ -51,6 +53,12 @@ archive_store.__index = archive_store; archive_store.users = _users; +archive_store.caps = { + total = true; + quota = archive_item_limit; + truncate = true; +}; + function archive_store:append(username, key, value, when, with) if is_stanza(value) then value = st.preserialize(value); @@ -70,6 +78,8 @@ function archive_store:append(username, key, value, when, with) end if a[key] then table.remove(a, a[key]); + elseif #a >= archive_item_limit then + return nil, "quota-limit"; end local i = #a+1; a[i] = v; @@ -80,9 +90,17 @@ end function archive_store:find(username, query) local items = self.store[username or NULL]; if not items then - return function () end, 0; + if query then + if query.before or query.after then + return nil, "item-not-found"; + end + if query.total then + return function () end, 0; + end + end + return function () end; end - local count = #items; + local count = nil; local i = 0; if query then items = array():append(items); @@ -106,24 +124,36 @@ function archive_store:find(username, query) return item.when <= query["end"]; end); end - count = #items; + if query.total then + count = #items; + end if query.reverse then items:reverse(); if query.before then - for j = 1, count do + local found = false; + for j = 1, #items do if (items[j].key or tostring(j)) == query.before then + found = true; i = j; break; end end + if not found then + return nil, "item-not-found"; + end end elseif query.after then - for j = 1, count do + local found = false; + for j = 1, #items do if (items[j].key or tostring(j)) == query.after then + found = true; i = j; break; end end + if not found then + return nil, "item-not-found"; + end end if query.limit and #items - i > query.limit then items[i+query.limit+1] = nil; @@ -137,6 +167,26 @@ function archive_store:find(username, query) end, count; end +function archive_store:summary(username, query) + local iter, err = self:find(username, query) + if not iter then return iter, err; end + local counts = {}; + local earliest = {}; + local latest = {}; + for _, _, when, with in iter do + counts[with] = (counts[with] or 0) + 1; + if earliest[with] == nil then + earliest[with] = when; + end + latest[with] = when; + end + return { + counts = counts; + earliest = earliest; + latest = latest; + }; +end + function archive_store:delete(username, query) if not query or next(query) == nil then diff --git a/plugins/mod_storage_sql.lua b/plugins/mod_storage_sql.lua index 5b1c3603..21556715 100644 --- a/plugins/mod_storage_sql.lua +++ b/plugins/mod_storage_sql.lua @@ -1,17 +1,19 @@ -- luacheck: ignore 212/self +local cache = require "util.cache"; local json = require "util.json"; local sql = require "util.sql"; local xml_parse = require "util.xml".parse; local uuid = require "util.uuid"; local resolve_relative_path = require "util.paths".resolve_relative_path; +local jid_join = require "util.jid".join; local is_stanza = require"util.stanza".is_stanza; local t_concat = table.concat; local noop = function() end -local unpack = table.unpack or unpack; +local unpack = table.unpack or unpack; -- luacheck: ignore 113 local function iterator(result) return function(result_) local row = result_(); @@ -148,7 +150,10 @@ end --- Archive store API --- luacheck: ignore 512 431/user 431/store +local archive_item_limit = module:get_option_number("storage_archive_item_limit"); +local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000)); + +-- luacheck: ignore 512 431/user 431/store 431/err local map_store = {}; map_store.__index = map_store; map_store.remove = {}; @@ -225,13 +230,88 @@ function map_store:set_keys(username, keydatas) return result; end +function map_store:get_all(key) + if type(key) ~= "string" or key == "" then + return nil, "get_all only supports non-empty string keys"; + end + local ok, result = engine:transaction(function() + local query = [[ + SELECT "user", "type", "value" + FROM "prosody" + WHERE "host"=? AND "store"=? AND "key"=? + ]]; + + local data; + for row in engine:select(query, host, self.store, key) do + local key_data, err = deserialize(row[2], row[3]); + assert(key_data ~= nil, err); + if data == nil then + data = {}; + end + data[row[1]] = key_data; + end + + return data; + + end); + if not ok then return nil, result; end + return result; +end + +function map_store:delete_all(key) + if type(key) ~= "string" or key == "" then + return nil, "delete_all only supports non-empty string keys"; + end + local ok, result = engine:transaction(function() + local delete_sql = [[ + DELETE FROM "prosody" + WHERE "host"=? AND "store"=? AND "key"=?; + ]]; + engine:delete(delete_sql, host, self.store, key); + return true; + end); + if not ok then return nil, result; end + return result; +end + local archive_store = {} archive_store.caps = { total = true; + quota = archive_item_limit; + truncate = true; }; archive_store.__index = archive_store function archive_store:append(username, key, value, when, with) local user,store = username,self.store; + local cache_key = jid_join(username, host, store); + local item_count = archive_item_count_cache:get(cache_key); + if not item_count then + local ok, ret = engine:transaction(function() + local count_sql = [[ + SELECT COUNT(*) FROM "prosodyarchive" + WHERE "host"=? AND "user"=? AND "store"=?; + ]]; + local result = engine:select(count_sql, host, user, store); + if result then + for row in result do + item_count = row[1]; + end + end + end); + if not ok or not item_count then + module:log("error", "Failed while checking quota for %s: %s", username, ret); + return nil, "Failure while checking quota"; + end + archive_item_count_cache:set(cache_key, item_count); + end + + if archive_item_limit then + module:log("debug", "%s has %d items out of %d limit", username, item_count, archive_item_limit); + if item_count >= archive_item_limit then + return nil, "quota-limit"; + end + end + when = when or os.time(); with = with or ""; local ok, ret = engine:transaction(function() @@ -245,12 +325,16 @@ function archive_store:append(username, key, value, when, with) VALUES (?,?,?,?,?,?,?,?); ]]; if key then - engine:delete(delete_sql, host, user or "", store, key); + local result = engine:delete(delete_sql, host, user or "", store, key); + if result then + item_count = item_count - result:affected(); + end else key = uuid.generate(); end local t, encoded_value = assert(serialize(value)); engine:insert(insert_sql, host, user or "", store, when, with, key, t, encoded_value); + archive_item_count_cache:set(cache_key, item_count+1); return key; end); if not ok then return ok, ret; end @@ -287,45 +371,47 @@ local function archive_where(query, args, where) end end local function archive_where_id_range(query, args, where) - local args_len = #args -- Before or after specific item, exclusive + local id_lookup_sql = [[ + SELECT "sort_id" + FROM "prosodyarchive" + WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ? + LIMIT 1; + ]]; if query.after then -- keys better be unique! - where[#where+1] = [[ - "sort_id" > COALESCE( - ( - SELECT "sort_id" - FROM "prosodyarchive" - WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ? - LIMIT 1 - ), 0) - ]]; - args[args_len+1], args[args_len+2], args[args_len+3], args[args_len+4] = query.after, args[1], args[2], args[3]; - args_len = args_len + 4 + local after_id = nil; + for row in engine:select(id_lookup_sql, query.after, args[1], args[2], args[3]) do + after_id = row[1]; + end + if not after_id then + return nil, "item-not-found"; + end + where[#where+1] = '"sort_id" > ?'; + args[#args+1] = after_id; end if query.before then - where[#where+1] = [[ - "sort_id" < COALESCE( - ( - SELECT "sort_id" - FROM "prosodyarchive" - WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ? - LIMIT 1 - ), - ( - SELECT MAX("sort_id")+1 - FROM "prosodyarchive" - ) - ) - ]] - args[args_len+1], args[args_len+2], args[args_len+3], args[args_len+4] = query.before, args[1], args[2], args[3]; + local before_id = nil; + for row in engine:select(id_lookup_sql, query.after, args[1], args[2], args[3]) do + before_id = row[1]; + end + if not before_id then + return nil, "item-not-found"; + end + where[#where+1] = '"sort_id" < ?'; + args[#args+1] = before_id; end + return true; end function archive_store:find(username, query) query = query or {}; local user,store = username,self.store; - local total; - local ok, result = engine:transaction(function() + local cache_key = jid_join(username, host, self.store); + local total = archive_item_count_cache:get(cache_key); + if total ~= nil and query.limit == 0 and query.start == nil and query.with == nil and query["end"] == nil and query.key == nil then + return noop, total; + end + local ok, result, err = engine:transaction(function() local sql_query = [[ SELECT "key", "type", "value", "when", "with" FROM "prosodyarchive" @@ -346,12 +432,16 @@ function archive_store:find(username, query) total = row[1]; end end + if query.start == nil and query.with == nil and query["end"] == nil and query.key == nil then + archive_item_count_cache:set(cache_key, total); + end if query.limit == 0 then -- Skip the real query return noop, total; end end - archive_where_id_range(query, args, where); + local ok, err = archive_where_id_range(query, args, where); + if not ok then return ok, err; end if query.limit then args[#args+1] = query.limit; @@ -361,7 +451,8 @@ function archive_store:find(username, query) and "DESC" or "ASC", query.limit and " LIMIT ?" or ""); return engine:select(sql_query, unpack(args)); end); - if not ok then return ok, result end + if not ok then return ok, result; end + if not result then return nil, err; end return function() local row = result(); if row ~= nil then @@ -372,6 +463,48 @@ function archive_store:find(username, query) end, total; end +function archive_store:summary(username, query) + query = query or {}; + local user,store = username,self.store; + local ok, result = engine:transaction(function() + local sql_query = [[ + SELECT DISTINCT "with", COUNT(*), MIN("when"), MAX("when") + FROM "prosodyarchive" + WHERE %s + GROUP BY "with" + ORDER BY "sort_id" %s%s; + ]]; + local args = { host, user or "", store, }; + local where = { "\"host\" = ?", "\"user\" = ?", "\"store\" = ?", }; + + archive_where(query, args, where); + + archive_where_id_range(query, args, where); + + if query.limit then + args[#args+1] = query.limit; + end + + sql_query = sql_query:format(t_concat(where, " AND "), query.reverse + and "DESC" or "ASC", query.limit and " LIMIT ?" or ""); + return engine:select(sql_query, unpack(args)); + end); + if not ok then return ok, result end + local counts = {}; + local earliest, latest = {}, {}; + for row in result do + local with, count = row[1], row[2]; + counts[with] = count; + earliest[with] = row[3]; + latest[with] = row[4]; + end + return { + counts = counts; + earliest = earliest; + latest = latest; + }; +end + function archive_store:delete(username, query) query = query or {}; local user,store = username,self.store; @@ -384,7 +517,8 @@ function archive_store:delete(username, query) table.remove(where, 2); end archive_where(query, args, where); - archive_where_id_range(query, args, where); + local ok, err = archive_where_id_range(query, args, where); + if not ok then return ok, err; end if query.truncate == nil then sql_query = sql_query:format(t_concat(where, " AND ")); else @@ -423,9 +557,24 @@ function archive_store:delete(username, query) end return engine:delete(sql_query, unpack(args)); end); + local cache_key = jid_join(username, host, self.store); + archive_item_count_cache:set(cache_key, nil); return ok and stmt:affected(), stmt; end +function archive_store:users() + local ok, result = engine:transaction(function() + local select_sql = [[ + SELECT DISTINCT "user" + FROM "prosodyarchive" + WHERE "host"=? AND "store"=?; + ]]; + return engine:select(select_sql, host, self.store); + end); + if not ok then error(result); end + return iterator(result); +end + local stores = { keyval = keyval_store; map = map_store; diff --git a/plugins/mod_tls.lua b/plugins/mod_tls.lua index 8c0c4e20..151add6f 100644 --- a/plugins/mod_tls.lua +++ b/plugins/mod_tls.lua @@ -35,9 +35,10 @@ local host = hosts[module.host]; local ssl_ctx_c2s, ssl_ctx_s2sout, ssl_ctx_s2sin; local ssl_cfg_c2s, ssl_cfg_s2sout, ssl_cfg_s2sin; +local err_c2s, err_s2sin, err_s2sout; function module.load(reload) - local NULL, err = {}; + local NULL = {}; local modhost = module.host; local parent = modhost:match("%.(.*)$"); @@ -53,16 +54,20 @@ function module.load(reload) local host_s2s = rawgetopt(modhost, "s2s_ssl") or parent_s2s; module:log("debug", "Creating context for c2s"); - ssl_ctx_c2s, err, ssl_cfg_c2s = create_context(host.host, "server", host_c2s, host_ssl, global_c2s); -- for incoming client connections - if not ssl_ctx_c2s then module:log("error", "Error creating context for c2s: %s", err); end + local request_client_certs = { verify = { "peer", "client_once", }; }; module:log("debug", "Creating context for s2sout"); - ssl_ctx_s2sout, err, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s); -- for outgoing server connections - if not ssl_ctx_s2sout then module:log("error", "Error creating contexts for s2sout: %s", err); end + ssl_ctx_c2s, err_c2s, ssl_cfg_c2s = create_context(host.host, "server", host_c2s, host_ssl, global_c2s); -- for incoming client connections + if not ssl_ctx_c2s then module:log("error", "Error creating context for c2s: %s", err_c2s); end module:log("debug", "Creating context for s2sin"); - ssl_ctx_s2sin, err, ssl_cfg_s2sin = create_context(host.host, "server", host_s2s, host_ssl, global_s2s); -- for incoming server connections - if not ssl_ctx_s2sin then module:log("error", "Error creating contexts for s2sin: %s", err); end + -- for outgoing server connections + ssl_ctx_s2sout, err_s2sout, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s, request_client_certs); + if not ssl_ctx_s2sout then module:log("error", "Error creating contexts for s2sout: %s", err_s2sout); end + + -- for incoming server connections + ssl_ctx_s2sin, err_s2sin, ssl_cfg_s2sin = create_context(host.host, "server", host_s2s, host_ssl, global_s2s, request_client_certs); + if not ssl_ctx_s2sin then module:log("error", "Error creating contexts for s2sin: %s", err_s2sin); end if reload then module:log("info", "Certificates reloaded"); @@ -83,12 +88,21 @@ local function can_do_tls(session) return session.ssl_ctx; end if session.type == "c2s_unauthed" then + if not ssl_ctx_c2s and c2s_require_encryption then + session.log("error", "No TLS context available for c2s. Earlier error was: %s", err_c2s); + end session.ssl_ctx = ssl_ctx_c2s; session.ssl_cfg = ssl_cfg_c2s; elseif session.type == "s2sin_unauthed" and allow_s2s_tls then + if not ssl_ctx_s2sin and s2s_require_encryption then + session.log("error", "No TLS context available for s2sin. Earlier error was: %s", err_s2sin); + end session.ssl_ctx = ssl_ctx_s2sin; session.ssl_cfg = ssl_cfg_s2sin; elseif session.direction == "outgoing" and allow_s2s_tls then + if not ssl_ctx_s2sout and s2s_require_encryption then + session.log("error", "No TLS context available for s2sout. Earlier error was: %s", err_s2sout); + end session.ssl_ctx = ssl_ctx_s2sout; session.ssl_cfg = ssl_cfg_s2sout; else diff --git a/plugins/mod_tokenauth.lua b/plugins/mod_tokenauth.lua new file mode 100644 index 00000000..c04a1aa4 --- /dev/null +++ b/plugins/mod_tokenauth.lua @@ -0,0 +1,82 @@ +local id = require "util.id"; +local jid = require "util.jid"; +local base64 = require "util.encodings".base64; + +local token_store = module:open_store("auth_tokens", "map"); + +function create_jid_token(actor_jid, token_jid, token_scope, token_ttl) + token_jid = jid.prep(token_jid); + if not actor_jid or token_jid ~= actor_jid and not jid.compare(token_jid, actor_jid) then + return nil, "not-authorized"; + end + + local token_username, token_host, token_resource = jid.split(token_jid); + + if token_host ~= module.host then + return nil, "invalid-host"; + end + + local token_info = { + owner = actor_jid; + created = os.time(); + expires = token_ttl and (os.time() + token_ttl) or nil; + jid = token_jid; + session = { + username = token_username; + host = token_host; + resource = token_resource; + + auth_scope = token_scope; + }; + }; + + local token_id = id.long(); + local token = base64.encode("1;"..jid.join(token_username, token_host)..";"..token_id); + token_store:set(token_username, token_id, token_info); + + return token, token_info; +end + +local function parse_token(encoded_token) + local token = base64.decode(encoded_token); + if not token then return nil; end + local token_jid, token_id = token:match("^1;([^;]+);(.+)$"); + if not token_jid then return nil; end + local token_user, token_host = jid.split(token_jid); + return token_id, token_user, token_host; +end + +function get_token_info(token) + local token_id, token_user, token_host = parse_token(token); + if not token_id then + return nil, "invalid-token-format"; + end + if token_host ~= module.host then + return nil, "invalid-host"; + end + + local token_info, err = token_store:get(token_user, token_id); + if not token_info then + if err then + return nil, "internal-error"; + end + return nil, "not-authorized"; + end + + if token_info.expires and token_info.expires < os.time() then + return nil, "not-authorized"; + end + + return token_info +end + +function revoke_token(token) + local token_id, token_user, token_host = parse_token(token); + if not token_id then + return nil, "invalid-token-format"; + end + if token_host ~= module.host then + return nil, "invalid-host"; + end + return token_store:set(token_user, token_id, nil); +end diff --git a/plugins/mod_uptime.lua b/plugins/mod_uptime.lua index ccd8e511..8a01fb17 100644 --- a/plugins/mod_uptime.lua +++ b/plugins/mod_uptime.lua @@ -16,7 +16,7 @@ module:add_feature("jabber:iq:last"); module:hook("iq-get/host/jabber:iq:last:query", function(event) local origin, stanza = event.origin, event.stanza; - origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))})); + origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(("%d"):format(os.difftime(os.time(), start_time)))})); return true; end); @@ -42,6 +42,6 @@ function uptime_command_handler () return { info = uptime_text(), status = "completed" }; end -local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler); +local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler, "any"); module:provides("adhoc", descriptor); diff --git a/plugins/mod_user_account_management.lua b/plugins/mod_user_account_management.lua index 615c1ed6..130ed089 100644 --- a/plugins/mod_user_account_management.lua +++ b/plugins/mod_user_account_management.lua @@ -53,9 +53,10 @@ local function handle_registration_stanza(event) log("info", "User removed their account: %s@%s", username, host); module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session }); else - local username = nodeprep(query:get_child_text("username")); + local username = query:get_child_text("username"); local password = query:get_child_text("password"); if username and password then + username = nodeprep(username); if username == session.username then if usermanager_set_password(username, password, session.host, session.resource) then session.send(st.reply(stanza)); diff --git a/plugins/mod_vcard.lua b/plugins/mod_vcard.lua index b1a4c6e8..c3d6fb8b 100644 --- a/plugins/mod_vcard.lua +++ b/plugins/mod_vcard.lua @@ -19,7 +19,7 @@ local function handle_vcard(event) if stanza.attr.type == "get" then local vCard; if to then - local node, host = jid_split(to); + local node = jid_split(to); vCard = st.deserialize(vcards:get(node)); -- load vCard for user or server else vCard = st.deserialize(vcards:get(session.username));-- load user's own vCard diff --git a/plugins/mod_vcard_legacy.lua b/plugins/mod_vcard_legacy.lua index 5e75947a..a6ff47d0 100644 --- a/plugins/mod_vcard_legacy.lua +++ b/plugins/mod_vcard_legacy.lua @@ -38,7 +38,7 @@ local simple_map = { module:hook("iq-get/bare/vcard-temp:vCard", function (event) local origin, stanza = event.origin, event.stanza; local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username); - local ok, id, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from); + local ok, _, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from); local vcard_temp = st.stanza("vCard", { xmlns = "vcard-temp" }); if ok and vcard4_item then @@ -105,6 +105,23 @@ module:hook("iq-get/bare/vcard-temp:vCard", function (event) vcard_temp:tag("WORK"):up(); end vcard_temp:up(); + elseif tag.name == "impp" then + local uri = tag:get_child_text("uri"); + if uri and uri:sub(1, 5) == "xmpp:" then + vcard_temp:text_tag("JABBERID", uri:sub(6)) + end + elseif tag.name == "org" then + vcard_temp:tag("ORG") + :text_tag("ORGNAME", tag:get_child_text("text")) + :up(); + end + end + else + local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", stanza.attr.from); + if ok and nick_item then + local nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick"); + if nickname then + vcard_temp:text_tag("NICKNAME", nickname); end end end @@ -216,6 +233,10 @@ function vcard_to_pep(vcard_temp) vcard4:text_tag("text", "work"); end vcard4:up():up():up(); + elseif tag.name == "JABBERID" then + vcard4:tag("impp") + :text_tag("uri", "xmpp:" .. tag:get_text()) + :up(); elseif tag.name == "PHOTO" then local avatar_type = tag:get_child_text("TYPE"); local avatar_payload = tag:get_child_text("BINVAL"); diff --git a/plugins/mod_websocket.lua b/plugins/mod_websocket.lua index 177259e6..1a0c0046 100644 --- a/plugins/mod_websocket.lua +++ b/plugins/mod_websocket.lua @@ -29,18 +29,10 @@ local t_concat = table.concat; local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5); local consider_websocket_secure = module:get_option_boolean("consider_websocket_secure"); -local cross_domain = module:get_option_set("cross_domain_websocket", {}); -if cross_domain:contains("*") or cross_domain:contains(true) then - cross_domain = true; +local cross_domain = module:get_option("cross_domain_websocket"); +if cross_domain ~= nil then + module:log("info", "The 'cross_domain_websocket' option has been deprecated"); end - -local function check_origin(origin) - if cross_domain == true then - return true; - end - return cross_domain:contains(origin); -end - local xmlns_framing = "urn:ietf:params:xml:ns:xmpp-framing"; local xmlns_streams = "http://etherx.jabber.org/streams"; local xmlns_client = "jabber:client"; @@ -88,7 +80,7 @@ local function session_close(session, reason) stream_error = reason; end end - log("debug", "Disconnecting client, <stream:error> is: %s", tostring(stream_error)); + log("debug", "Disconnecting client, <stream:error> is: %s", stream_error); session.send(stream_error); end @@ -138,17 +130,21 @@ local function filter_open_close(data) return data; end + +local default_get_response_body = [[<!DOCTYPE html><html><head><title>Websocket</title></head><body> +<p>It works! Now point your WebSocket client to this URL to connect to Prosody.</p> +</body></html>]] +local websocket_get_response_body = module:get_option_string("websocket_get_response_body", default_get_response_body) + function handle_request(event) local request, response = event.request, event.response; local conn = response.conn; conn.starttls = false; -- Prevent mod_tls from believing starttls can be done - if not request.headers.sec_websocket_key then + if not request.headers.sec_websocket_key or request.method ~= "GET" then response.headers.content_type = "text/html"; - return [[<!DOCTYPE html><html><head><title>Websocket</title></head><body> - <p>It works! Now point your WebSocket client to this URL to connect to Prosody.</p> - </body></html>]]; + return websocket_get_response_body; end local wants_xmpp = contains_token(request.headers.sec_websocket_protocol or "", "xmpp"); @@ -158,11 +154,6 @@ function handle_request(event) return 501; end - if not check_origin(request.headers.origin or "") then - module:log("debug", "Origin %s is not allowed by 'cross_domain_websocket' [ %s ]", request.headers.origin or "(missing header)", cross_domain); - return 403; - end - local function websocket_close(code, message) conn:write(build_close(code, message)); conn:close(); @@ -333,27 +324,4 @@ module:provides("http", { function module.add_host(module) module:hook("c2s-read-timeout", keepalive, -0.9); - - if cross_domain ~= true then - local url = require "socket.url"; - local ws_url = module:http_url("websocket", "xmpp-websocket"); - local url_components = url.parse(ws_url); - -- The 'Origin' consists of the base URL without path - url_components.path = nil; - local this_origin = url.build(url_components); - local local_cross_domain = module:get_option_set("cross_domain_websocket", { this_origin }); - if local_cross_domain:contains(true) then - module:log("error", "cross_domain_websocket = true only works in the global section"); - return; - end - - -- Don't add / remove something added by another host - -- This might be weird with random load order - local_cross_domain:exclude(cross_domain); - cross_domain:include(local_cross_domain); - module:log("debug", "cross_domain = %s", tostring(cross_domain)); - function module.unload() - cross_domain:exclude(local_cross_domain); - end - end end diff --git a/plugins/muc/hats.lib.lua b/plugins/muc/hats.lib.lua new file mode 100644 index 00000000..77051af2 --- /dev/null +++ b/plugins/muc/hats.lib.lua @@ -0,0 +1,24 @@ +local st = require "util.stanza"; +local muc_util = module:require "muc/util"; + +local xmlns_hats = "xmpp:prosody.im/protocol/hats:1"; + +-- Strip any hats claimed by the client (to prevent spoofing) +muc_util.add_filtered_namespace(xmlns_hats); + +module:hook("muc-build-occupant-presence", function (event) + local aff_data = event.room:get_affiliation_data(event.occupant.bare_jid); + local hats = aff_data and aff_data.hats; + if not hats then return; end + local hats_el; + for hat_id, hat_data in pairs(hats) do + if hat_data.active then + if not hats_el then + hats_el = st.stanza("hats", { xmlns = xmlns_hats }); + end + hats_el:tag("hat", { uri = hat_id, title = hat_data.title }):up(); + end + end + if not hats_el then return; end + event.stanza:add_direct_child(hats_el); +end); diff --git a/plugins/muc/history.lib.lua b/plugins/muc/history.lib.lua index 0d69c97d..1b7167af 100644 --- a/plugins/muc/history.lib.lua +++ b/plugins/muc/history.lib.lua @@ -48,16 +48,18 @@ module:hook("muc-config-form", function(event) table.insert(event.form, { name = "muc#roomconfig_historylength"; type = "text-single"; + datatype = "xs:integer"; label = "Maximum number of history messages returned by room"; desc = "Specify the maximum number of previous messages that should be sent to users when they join the room"; - value = tostring(get_historylength(event.room)); + value = get_historylength(event.room); }); table.insert(event.form, { name = 'muc#roomconfig_defaulthistorymessages', type = 'text-single', + datatype = "xs:integer"; label = 'Default number of history messages returned by room', desc = "Specify the number of previous messages sent to new users when they join the room"; - value = tostring(get_defaulthistorymessages(event.room)) + value = get_defaulthistorymessages(event.room); }); end, 70-5); @@ -198,7 +200,27 @@ module:hook("muc-broadcast-message", function(event) end); module:hook("muc-message-is-historic", function (event) - return event.stanza:get_child("body"); + local stanza = event.stanza; + if stanza:get_child("no-store", "urn:xmpp:hints") + or stanza:get_child("no-permanent-store", "urn:xmpp:hints") then + -- XXX Experimental XEP + return false, "hint"; + end + if stanza:get_child("store", "urn:xmpp:hints") then + return true, "hint"; + end + if stanza:get_child("body") then + return true; + end + if stanza:get_child("encryption", "urn:xmpp:eme:0") then + -- Since we can't know what an encrypted message contains, we assume it's important + -- XXX Experimental XEP + return true, "encrypted"; + end + if stanza:get_child(nil, "urn:xmpp:chat-markers:0") then + -- XXX Experimental XEP + return true, "marker"; + end end, -1); return { diff --git a/plugins/muc/language.lib.lua b/plugins/muc/language.lib.lua index ee80806b..2ee2ba0f 100644 --- a/plugins/muc/language.lib.lua +++ b/plugins/muc/language.lib.lua @@ -32,6 +32,7 @@ local function add_form_option(event) label = "Language tag for room (e.g. 'en', 'de', 'fr' etc.)"; type = "text-single"; desc = "Indicate the primary language spoken in this room"; + datatype = "xs:language"; value = get_language(event.room) or ""; }); end diff --git a/plugins/muc/lock.lib.lua b/plugins/muc/lock.lib.lua index 062ab615..32f2647b 100644 --- a/plugins/muc/lock.lib.lua +++ b/plugins/muc/lock.lib.lua @@ -43,7 +43,7 @@ end module:hook("muc-occupant-pre-join", function(event) if not event.is_new_room and is_locked(event.room) then -- Deny entry module:log("debug", "Room is locked, denying entry"); - event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found")); + event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found", nil, module.host)); return true; end end, -30); diff --git a/plugins/muc/members_only.lib.lua b/plugins/muc/members_only.lib.lua index 4194c5c7..79077153 100644 --- a/plugins/muc/members_only.lib.lua +++ b/plugins/muc/members_only.lib.lua @@ -113,7 +113,7 @@ module:hook("muc-occupant-pre-join", function(event) local stanza = event.stanza; local affiliation = room:get_affiliation(stanza.attr.from); if valid_affiliations[affiliation or "none"] <= valid_affiliations.none then - local reply = st.error_reply(stanza, "auth", "registration-required"):up(); + local reply = st.error_reply(stanza, "auth", "registration-required", nil, room.jid):up(); reply.tags[1].attr.code = "407"; event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); return true; @@ -131,7 +131,7 @@ module:hook("muc-pre-invite", function(event) local inviter_affiliation = room:get_affiliation(stanza.attr.from) or "none"; local required_affiliation = room._data.allow_member_invites and "member" or "admin"; if valid_affiliations[inviter_affiliation] < valid_affiliations[required_affiliation] then - event.origin.send(st.error_reply(stanza, "auth", "forbidden")); + event.origin.send(st.error_reply(stanza, "auth", "forbidden", nil, room.jid)); return true; end end diff --git a/plugins/muc/mod_muc.lua b/plugins/muc/mod_muc.lua index b9c7c859..d911de08 100644 --- a/plugins/muc/mod_muc.lua +++ b/plugins/muc/mod_muc.lua @@ -86,7 +86,14 @@ room_mt.get_registered_nick = register.get_registered_nick; room_mt.get_registered_jid = register.get_registered_jid; room_mt.handle_register_iq = register.handle_register_iq; +local presence_broadcast = module:require "muc/presence_broadcast"; +room_mt.get_presence_broadcast = presence_broadcast.get; +room_mt.set_presence_broadcast = presence_broadcast.set; +room_mt.get_valid_broadcast_roles = presence_broadcast.get_valid_broadcast_roles; + + local jid_split = require "util.jid".split; +local jid_prep = require "util.jid".prep; local jid_bare = require "util.jid".bare; local st = require "util.stanza"; local cache = require "util.cache"; @@ -98,6 +105,7 @@ module:depends("disco"); module:add_identity("conference", "text", module:get_option_string("name", "Prosody Chatrooms")); module:add_feature("http://jabber.org/protocol/muc"); module:depends "muc_unique" +module:require "muc/hats"; module:require "muc/lock"; local function is_admin(jid) @@ -185,7 +193,7 @@ end local function handle_broken_room(room, origin, stanza) module:log("debug", "Returning error from broken room %s", room.jid); - origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + origin.send(st.error_reply(stanza, "wait", "internal-server-error", nil, room.jid)); return true; end @@ -264,9 +272,13 @@ local function set_room_defaults(room, lang) room:set_changesubject(module:get_option_boolean("muc_room_default_change_subject", room:get_changesubject())); room:set_historylength(module:get_option_number("muc_room_default_history_length", room:get_historylength())); room:set_language(lang or module:get_option_string("muc_room_default_language")); + room:set_presence_broadcast(module:get_option("muc_room_default_presence_broadcast", room:get_presence_broadcast())); end function create_room(room_jid, config) + if jid_bare(room_jid) ~= room_jid or not jid_prep(room_jid, true) then + return nil, "invalid-jid"; + end local exists = get_room_from_jid(room_jid); if exists then return nil, "room-exists"; @@ -345,7 +357,7 @@ end, 1); module:hook("muc-room-pre-create", function(event) local origin, stanza = event.origin, event.stanza; if not track_room(event.room) then - origin.send(st.error_reply(stanza, "wait", "resource-constraint")); + origin.send(st.error_reply(stanza, "wait", "resource-constraint", nil, module.host)); return true; end end, -1000); @@ -396,7 +408,7 @@ do restrict_room_creation == "local" and select(2, jid_split(user_jid)) == host_suffix ) then - origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted")); + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted", module.host)); return true; end end); @@ -441,7 +453,7 @@ for event_name, method in pairs { room = nil; else if stanza.attr.type ~= "error" then - local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason) + local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason, module.host) if room._data.newjid then local uri = "xmpp:"..room._data.newjid.."?join"; reply:get_child("error"):child_with_name("gone"):text(uri); @@ -454,17 +466,21 @@ for event_name, method in pairs { if room == nil then -- Watch presence to create rooms - if stanza.attr.type == nil and stanza.name == "presence" then + if not jid_prep(room_jid, true) then + origin.send(st.error_reply(stanza, "modify", "jid-malformed", nil, module.host)); + return true; + end + if stanza.attr.type == nil and stanza.name == "presence" and stanza:get_child("x", "http://jabber.org/protocol/muc") then room = muclib.new_room(room_jid); return room:handle_first_presence(origin, stanza); elseif stanza.attr.type ~= "error" then - origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + origin.send(st.error_reply(stanza, "cancel", "item-not-found", nil, module.host)); return true; else return; end elseif room == false then -- Error loading room - origin.send(st.error_reply(stanza, "wait", "resource-constraint")); + origin.send(st.error_reply(stanza, "wait", "resource-constraint", nil, module.host)); return true; end return room[method](room, origin, stanza); @@ -483,6 +499,7 @@ do -- Ad-hoc commands local t_concat = table.concat; local adhoc_new = module:require "adhoc".new; local adhoc_initial = require "util.adhoc".new_initial_data_form; + local adhoc_simple = require "util.adhoc".new_simple_form; local array = require "util.array"; local dataforms_new = require "util.dataforms".new; @@ -513,4 +530,46 @@ do -- Ad-hoc commands "http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin"); module:provides("adhoc", destroy_rooms_desc); + + + local set_affiliation_layout = dataforms_new { + -- FIXME wordsmith title, instructions, labels etc + title = "Set affiliation"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#set-affiliation" }; + { name = "room", type = "jid-single", required = true, label = "Room"}; + { name = "jid", type = "jid-single", required = true, label = "JID"}; + { name = "affiliation", type = "list-single", required = true, label = "Affiliation", + options = { "owner"; "admin"; "member"; "none"; "outcast"; }, + }; + { name = "reason", type = "text-single", "Reason", } + }; + + local set_affiliation_handler = adhoc_simple(set_affiliation_layout, function (fields, errors) + if errors then + local errmsg = {}; + for field, err in pairs(errors) do + errmsg[#errmsg + 1] = field .. ": " .. err; + end + return { status = "completed", error = { message = t_concat(errmsg, "\n") } }; + end + + local room = get_room_from_jid(fields.room); + if not room then + return { status = "canceled", error = { message = "No such room"; }; }; + end + local ok, err, condition = room:set_affiliation(true, fields.jid, fields.affiliation, fields.reason); + + if not ok then + return { status = "canceled", error = { message = "Affiliation change failed: "..err..":"..condition; }; }; + end + + return { status = "completed", info = "Affiliation updated", + }; + end); + + local set_affiliation_desc = adhoc_new("Set affiliation in room", + "http://prosody.im/protocol/muc#set-affiliation", set_affiliation_handler, "admin"); + + module:provides("adhoc", set_affiliation_desc); end diff --git a/plugins/muc/muc.lib.lua b/plugins/muc/muc.lib.lua index 93334b88..d785a5a2 100644 --- a/plugins/muc/muc.lib.lua +++ b/plugins/muc/muc.lib.lua @@ -23,6 +23,7 @@ local resourceprep = require "util.encodings".stringprep.resourceprep; local st = require "util.stanza"; local base64 = require "util.encodings".base64; local md5 = require "util.hashes".md5; +local new_id = require "util.id".medium; local log = module._log; @@ -39,7 +40,7 @@ function room_mt:__tostring() end function room_mt.save() - -- overriden by mod_muc.lua + -- overridden by mod_muc.lua end function room_mt:get_occupant_jid(real_jid) @@ -217,13 +218,13 @@ end -- Broadcasts an occupant's presence to the whole room -- Takes the x element that goes into the stanzas -function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason) +function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason, prev_role, force_unavailable) local base_x = x.base or x; -- Build real jid and (optionally) occupant jid template presences local base_presence do -- Try to use main jid's presence local pr = occupant:get_presence(); - if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") then + if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") and not force_unavailable then base_presence = st.clone(pr); else -- user is leaving but didn't send a leave presence. make one for them base_presence = st.presence {from = occupant.nick; type = "unavailable";}; @@ -236,6 +237,7 @@ function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason) occupant = occupant; nick = nick; actor = actor; reason = reason; } + module:fire_event("muc-build-occupant-presence", event); module:fire_event("muc-broadcast-presence", event); -- Allow muc-broadcast-presence listeners to change things @@ -279,7 +281,9 @@ function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason) self_p = st.clone(base_presence):add_child(self_x); end - -- General populance + local broadcast_roles = self:get_presence_broadcast(); + + -- General populace for occupant_nick, n_occupant in self:each_occupant() do if occupant_nick ~= occupant.nick then local pr; @@ -290,7 +294,13 @@ function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason) else pr = get_anon_p(); end - self:route_to_occupant(n_occupant, pr); + if broadcast_roles[occupant.role or "none"] or force_unavailable then + self:route_to_occupant(n_occupant, pr); + elseif prev_role and broadcast_roles[prev_role] then + pr.attr.type = 'unavailable'; + self:route_to_occupant(n_occupant, pr); + end + end end @@ -314,6 +324,7 @@ function room_mt:send_occupant_list(to, filter) local to_bare = jid_bare(to); local is_anonymous = false; local whois = self:get_whois(); + local broadcast_roles = self:get_presence_broadcast(); if whois ~= "anyone" then local affiliation = self:get_affiliation(to); if affiliation ~= "admin" and affiliation ~= "owner" then @@ -323,14 +334,38 @@ function room_mt:send_occupant_list(to, filter) end end end + local broadcast_bare_jids = {}; -- Track which bare JIDs we have sent presence for for occupant_jid, occupant in self:each_occupant() do + broadcast_bare_jids[occupant.bare_jid] = true; if filter == nil or filter(occupant_jid, occupant) then local x = st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'}); self:build_item_list(occupant, x, is_anonymous and to_bare ~= occupant.bare_jid); -- can always see your own jids local pres = st.clone(occupant:get_presence()); pres.attr.to = to; pres:add_child(x); - self:route_stanza(pres); + module:fire_event("muc-build-occupant-presence", { room = self, occupant = occupant, stanza = pres }); + if to_bare == occupant.bare_jid or broadcast_roles[occupant.role or "none"] then + self:route_stanza(pres); + end + end + end + if broadcast_roles.none then + -- Broadcast stanzas for affiliated users not currently in the MUC + for affiliated_jid, affiliation, affiliation_data in self:each_affiliation() do + local nick = affiliation_data and affiliation_data.reserved_nickname; + if (nick or not is_anonymous) and not broadcast_bare_jids[affiliated_jid] + and (filter == nil or filter(affiliated_jid, nil)) then + local from = nick and (self.jid.."/"..nick) or self.jid; + local pres = st.presence({ to = to, from = from, type = "unavailable" }) + :tag("x", { xmlns = 'http://jabber.org/protocol/muc#user' }) + :tag("item", { + affiliation = affiliation; + role = "none"; + nick = nick; + jid = not is_anonymous and affiliated_jid or nil }):up() + :up(); + self:route_stanza(pres); + end end end end @@ -373,13 +408,14 @@ function room_mt:handle_kickable(origin, stanza) -- luacheck: ignore 212 local real_jid = stanza.attr.from; local occupant = self:get_occupant_by_real_jid(real_jid); if occupant == nil then return nil; end - local type, condition, text = stanza:get_error(); + local _, condition, text = stanza:get_error(); local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error"); if text and self:get_whois() == "anyone" then error_message = error_message..": "..text; end occupant:set_session(real_jid, st.presence({type="unavailable"}) :tag('status'):text(error_message)); + local orig_role = occupant.role; local is_last_session = occupant.jid == real_jid; if is_last_session then occupant.role = nil; @@ -389,9 +425,13 @@ function room_mt:handle_kickable(origin, stanza) -- luacheck: ignore 212 if is_last_session then x:tag("status", {code = "333"}); end - self:publicise_occupant_status(new_occupant or occupant, x); + self:publicise_occupant_status(new_occupant or occupant, x, nil, nil, nil, orig_role); if is_last_session then - module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + module:fire_event("muc-occupant-left", { + room = self; + nick = occupant.nick; + occupant = occupant; + }); end return true; end @@ -406,7 +446,7 @@ module:hook("muc-occupant-pre-join", function(event) local room, stanza = event.room, event.stanza; local affiliation = room:get_affiliation(stanza.attr.from); if affiliation == "outcast" then - local reply = st.error_reply(stanza, "auth", "forbidden"):up(); + local reply = st.error_reply(stanza, "auth", "forbidden", nil, room.jid):up(); reply.tags[1].attr.code = "403"; event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); return true; @@ -414,28 +454,41 @@ module:hook("muc-occupant-pre-join", function(event) end, -10); module:hook("muc-occupant-pre-join", function(event) + local room = event.room; local nick = jid_resource(event.occupant.nick); if not nick:find("%S") then - event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden")); + event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden", room.jid)); return true; end end, 1); module:hook("muc-occupant-pre-change", function(event) + local room = event.room; if not jid_resource(event.dest_occupant.nick):find("%S") then - event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden")); + event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden", room.jid)); return true; end end, 1); -function room_mt:handle_first_presence(origin, stanza) - if not stanza:get_child("x", "http://jabber.org/protocol/muc") then - module:log("debug", "Room creation without <x>, possibly desynced"); +module:hook("muc-occupant-pre-join", function(event) + local room = event.room; + local nick = jid_resource(event.occupant.nick); + if not resourceprep(nick, true) then -- strict + event.origin.send(st.error_reply(event.stanza, "modify", "jid-malformed", "Nickname must pass strict validation", room.jid)); + return true; + end +end, 2); - origin.send(st.error_reply(stanza, "cancel", "item-not-found")); +module:hook("muc-occupant-pre-change", function(event) + local room = event.room; + local nick = jid_resource(event.dest_occupant.nick); + if not resourceprep(nick, true) then -- strict + event.origin.send(st.error_reply(event.stanza, "modify", "jid-malformed", "Nickname must pass strict validation", room.jid)); return true; end +end, 2); +function room_mt:handle_first_presence(origin, stanza) local real_jid = stanza.attr.from; local dest_jid = stanza.attr.to; local bare_jid = jid_bare(real_jid); @@ -505,7 +558,7 @@ function room_mt:handle_normal_presence(origin, stanza) if orig_occupant == nil and not muc_x and stanza.attr.type == nil then module:log("debug", "Attempted join without <x>, possibly desynced"); origin.send(st.error_reply(stanza, "cancel", "item-not-found", - "You must join the room before sending presence updates")); + "You are not currently connected to this chat", self.jid)); return true; end @@ -567,7 +620,7 @@ function room_mt:handle_normal_presence(origin, stanza) and bare_jid ~= jid_bare(dest_occupant.bare_jid) then -- new nick or has different bare real jid log("debug", "%s couldn't join due to nick conflict: %s", real_jid, dest_occupant.nick); - local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + local reply = st.error_reply(stanza, "cancel", "conflict", nil, self.jid):up(); reply.tags[1].attr.code = "409"; origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); return true; @@ -576,6 +629,7 @@ function room_mt:handle_normal_presence(origin, stanza) -- Send presence stanza about original occupant if orig_occupant ~= nil and orig_occupant ~= dest_occupant then local orig_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + local orig_role = orig_occupant.role; local dest_nick; if dest_occupant == nil then -- Session is leaving log("debug", "session %s is leaving occupant %s", real_jid, orig_occupant.nick); @@ -613,12 +667,12 @@ function room_mt:handle_normal_presence(origin, stanza) x:tag("status", {code = "303";}):up(); x:tag("status", {code = "110";}):up(); self:route_stanza(generated_unavail:add_child(x)); - dest_nick = nil; -- set dest_nick to nil; so general populance doesn't see it for whole orig_occupant + dest_nick = nil; -- set dest_nick to nil; so general populace doesn't see it for whole orig_occupant end end self:save_occupant(orig_occupant); - self:publicise_occupant_status(orig_occupant, orig_x, dest_nick); + self:publicise_occupant_status(orig_occupant, orig_x, dest_nick, nil, nil, orig_role); if is_last_orig_session then module:fire_event("muc-occupant-left", { @@ -639,7 +693,7 @@ function room_mt:handle_normal_presence(origin, stanza) -- Send occupant list to newly joined or desynced user self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212 -- Don't include self - return occupant:get_presence(real_jid) == nil; + return (not occupant) or occupant:get_presence(real_jid) == nil; end) end local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); @@ -650,7 +704,7 @@ function room_mt:handle_normal_presence(origin, stanza) if nick_changed then self_x:tag("status", {code="210"}):up(); end - self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x}); + self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x}, nil, nil, nil, orig_occupant and orig_occupant.role or nil); if orig_occupant ~= nil and orig_occupant ~= dest_occupant and not is_last_orig_session then -- If user is swapping and wasn't last original session @@ -696,7 +750,7 @@ function room_mt:handle_presence_to_occupant(origin, stanza) return self:handle_normal_presence(origin, stanza); elseif type ~= 'result' then -- bad type if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences - origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error? + origin.send(st.error_reply(stanza, "modify", "bad-request", nil, self.jid)); -- FIXME correct error? end end return true; @@ -731,11 +785,11 @@ function room_mt:handle_iq_to_occupant(origin, stanza) else -- Type is "get" or "set" local current_nick = self:get_occupant_jid(from); if not current_nick then - origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat")); + origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid)); return true; end if not occupant then -- recipient not in room - origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room")); + origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room", self.jid)); return true; end -- XEP-0410 MUC Self-Ping #1220 @@ -764,12 +818,12 @@ function room_mt:handle_message_to_occupant(origin, stanza) local type = stanza.attr.type; if not current_nick then -- not in room if type ~= "error" then - origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat")); + origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid)); end return true; end if type == "groupchat" then -- groupchat messages not allowed in PM - origin.send(st.error_reply(stanza, "modify", "bad-request")); + origin.send(st.error_reply(stanza, "modify", "bad-request", nil, self.jid)); return true; elseif type == "error" and is_kickable_error(stanza) then log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid); @@ -778,14 +832,16 @@ function room_mt:handle_message_to_occupant(origin, stanza) local o_data = self:get_occupant_by_nick(to); if not o_data then - origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room")); + origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room", self.jid)); return true; end log("debug", "%s sent private message stanza to %s (%s)", from, to, o_data.jid); stanza = muc_util.filter_muc_x(st.clone(stanza)); stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up(); stanza.attr.from = current_nick; - self:route_to_occupant(o_data, stanza) + if module:fire_event("muc-private-message", { room = self, origin = origin, stanza = stanza }) ~= false then + self:route_to_occupant(o_data, stanza) + end -- TODO: Remove x tag? stanza.attr.from = from; return true; @@ -815,10 +871,12 @@ function room_mt:process_form(origin, stanza) if form.attr.type == "cancel" then origin.send(st.reply(stanza)); elseif form.attr.type == "submit" then + -- luacheck: ignore 231/errors local fields, errors, present; if form.tags[1] == nil then -- Instant room fields, present = {}, {}; else + -- FIXME handle form errors fields, errors, present = self:get_form_layout(stanza.attr.from):data(form); if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration")); @@ -873,13 +931,18 @@ function room_mt:clear(x) x = x or st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'}); local occupants_updated = {}; for nick, occupant in self:each_occupant() do -- luacheck: ignore 213 + local prev_role = occupant.role; occupant.role = nil; self:save_occupant(occupant); - occupants_updated[occupant] = true; + occupants_updated[occupant] = prev_role; end - for occupant in pairs(occupants_updated) do - self:publicise_occupant_status(occupant, x); - module:fire_event("muc-occupant-left", { room = self; nick = occupant.nick; occupant = occupant;}); + for occupant, prev_role in pairs(occupants_updated) do + self:publicise_occupant_status(occupant, x, nil, nil, nil, prev_role); + module:fire_event("muc-occupant-left", { + room = self; + nick = occupant.nick; + occupant = occupant; + }); end end @@ -972,7 +1035,7 @@ function room_mt:handle_admin_query_get_command(origin, stanza) local _aff_rank = valid_affiliations[_aff or "none"]; local _rol = item.attr.role; if _aff and _aff_rank and not _rol then - -- You need to be at least an admin, and be requesting info about your affifiliation or lower + -- You need to be at least an admin, and be requesting info about your affiliation or lower -- e.g. an admin can't ask for a list of owners local affiliation_rank = valid_affiliations[affiliation or "none"]; if (affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank) @@ -1049,6 +1112,9 @@ end function room_mt:handle_groupchat_to_room(origin, stanza) local from = stanza.attr.from; local occupant = self:get_occupant_by_real_jid(from); + if not stanza.attr.id then + stanza.attr.id = new_id() + end if module:fire_event("muc-occupant-groupchat", { room = self; origin = origin; stanza = stanza; from = from; occupant = occupant; }) then return true; end @@ -1218,7 +1284,7 @@ function room_mt:route_stanza(stanza) -- luacheck: ignore 212 end function room_mt:get_affiliation(jid) - local node, host, resource = jid_split(jid); + local node, host = jid_split(jid); -- Affiliations are granted, revoked, and maintained based on the user's bare JID. local bare = node and node.."@"..host or host; local result = self._affiliations[bare]; @@ -1241,7 +1307,7 @@ end function room_mt:set_affiliation(actor, jid, affiliation, reason, data) if not actor then return nil, "modify", "not-acceptable"; end; - local node, host, resource = jid_split(jid); + local node, host = jid_split(jid); if not host then return nil, "modify", "not-acceptable"; end jid = jid_join(node, host); -- Bare local is_host_only = node == nil; @@ -1297,7 +1363,7 @@ function room_mt:set_affiliation(actor, jid, affiliation, reason, data) -- Outcast can be by host. is_host_only and affiliation == "outcast" and select(2, jid_split(occupant.bare_jid)) == host ) then - -- need to publcize in all cases; as affiliation in <item/> has changed. + -- need to publicize in all cases; as affiliation in <item/> has changed. occupants_updated[occupant] = occupant.role; if occupant.role ~= role and ( is_downgrade or @@ -1322,16 +1388,20 @@ function room_mt:set_affiliation(actor, jid, affiliation, reason, data) if next(occupants_updated) ~= nil then for occupant, old_role in pairs(occupants_updated) do - self:publicise_occupant_status(occupant, x, nil, actor, reason); + self:publicise_occupant_status(occupant, x, nil, actor, reason, old_role); if occupant.role == nil then - module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + module:fire_event("muc-occupant-left", { + room = self; + nick = occupant.nick; + occupant = occupant; + }); elseif is_semi_anonymous and (old_role == "moderator" and occupant.role ~= "moderator") or (old_role ~= "moderator" and occupant.role == "moderator") then -- Has gained or lost moderator status -- Send everyone else's presences (as jid visibility has changed) for real_jid in occupant:each_session() do self:send_occupant_list(real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433 - return occupant.bare_jid ~= jid; + return (not occupant) or occupant.bare_jid ~= jid; end); end end @@ -1376,6 +1446,42 @@ function room_mt:get_role(nick) return occupant and occupant.role or nil; end +function room_mt:may_set_role(actor, occupant, role) + local event = { + room = self, + actor = actor, + occupant = occupant, + role = role, + }; + + module:fire_event("muc-pre-set-role", event); + if event.allowed ~= nil then + return event.allowed, event.error, event.condition; + end + + -- Can't do anything to other owners or admins + local occupant_affiliation = self:get_affiliation(occupant.bare_jid); + if occupant_affiliation == "owner" or occupant_affiliation == "admin" then + return nil, "cancel", "not-allowed"; + end + + -- If you are trying to give or take moderator role you need to be an owner or admin + if occupant.role == "moderator" or role == "moderator" then + local actor_affiliation = self:get_affiliation(actor); + if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then + return nil, "cancel", "not-allowed"; + end + end + + -- Need to be in the room and a moderator + local actor_occupant = self:get_occupant_by_real_jid(actor); + if not actor_occupant or actor_occupant.role ~= "moderator" then + return nil, "cancel", "not-allowed"; + end + + return true; +end + function room_mt:set_role(actor, occupant_jid, role, reason) if not actor then return nil, "modify", "not-acceptable"; end @@ -1390,24 +1496,9 @@ function room_mt:set_role(actor, occupant_jid, role, reason) if actor == true then actor = nil -- So we can pass it safely to 'publicise_occupant_status' below else - -- Can't do anything to other owners or admins - local occupant_affiliation = self:get_affiliation(occupant.bare_jid); - if occupant_affiliation == "owner" or occupant_affiliation == "admin" then - return nil, "cancel", "not-allowed"; - end - - -- If you are trying to give or take moderator role you need to be an owner or admin - if occupant.role == "moderator" or role == "moderator" then - local actor_affiliation = self:get_affiliation(actor); - if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then - return nil, "cancel", "not-allowed"; - end - end - - -- Need to be in the room and a moderator - local actor_occupant = self:get_occupant_by_real_jid(actor); - if not actor_occupant or actor_occupant.role ~= "moderator" then - return nil, "cancel", "not-allowed"; + local allowed, err, condition = self:may_set_role(actor, occupant, role) + if not allowed then + return allowed, err, condition; end end @@ -1415,11 +1506,17 @@ function room_mt:set_role(actor, occupant_jid, role, reason) if not role then x:tag("status", {code = "307"}):up(); end + + local prev_role = occupant.role; occupant.role = role; self:save_occupant(occupant); - self:publicise_occupant_status(occupant, x, nil, actor, reason); + self:publicise_occupant_status(occupant, x, nil, actor, reason, prev_role); if role == nil then - module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + module:fire_event("muc-occupant-left", { + room = self; + nick = occupant.nick; + occupant = occupant; + }); end return true; end @@ -1441,7 +1538,7 @@ function _M.new_room(jid, config) }, room_mt); end -local new_format = module:get_option_boolean("new_muc_storage_format", false); +local new_format = module:get_option_boolean("new_muc_storage_format", true); function room_mt:freeze(live) local frozen, state; @@ -1505,7 +1602,7 @@ function _M.restore_room(frozen, state) else -- New storage format for jid, data in pairs(frozen) do - local node, host, resource = jid_split(jid); + local _, host, resource = jid_split(jid); if host:sub(1,1) ~= "_" and not resource and type(data) == "string" then -- bare jid: affiliation room._affiliations[jid] = data; diff --git a/plugins/muc/password.lib.lua b/plugins/muc/password.lib.lua index 1f4b2add..6695c0cf 100644 --- a/plugins/muc/password.lib.lua +++ b/plugins/muc/password.lib.lua @@ -50,7 +50,7 @@ module:hook("muc-occupant-pre-join", function(event) if get_password(room) ~= password then local from, to = stanza.attr.from, stanza.attr.to; module:log("debug", "%s couldn't join due to invalid password: %s", from, to); - local reply = st.error_reply(stanza, "auth", "not-authorized"):up(); + local reply = st.error_reply(stanza, "auth", "not-authorized", nil, room.jid):up(); reply.tags[1].attr.code = "401"; event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); return true; diff --git a/plugins/muc/presence_broadcast.lib.lua b/plugins/muc/presence_broadcast.lib.lua new file mode 100644 index 00000000..82a89fee --- /dev/null +++ b/plugins/muc/presence_broadcast.lib.lua @@ -0,0 +1,83 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; + +local valid_roles = { "none", "visitor", "participant", "moderator" }; +local default_broadcast = { + visitor = true; + participant = true; + moderator = true; +}; + +local function get_presence_broadcast(room) + return room._data.presence_broadcast or default_broadcast; +end + +local function set_presence_broadcast(room, broadcast_roles) + broadcast_roles = broadcast_roles or default_broadcast; + + local changed = false; + local old_broadcast_roles = get_presence_broadcast(room); + for _, role in ipairs(valid_roles) do + if old_broadcast_roles[role] ~= broadcast_roles[role] then + changed = true; + end + end + + if not changed then return false; end + + room._data.presence_broadcast = broadcast_roles; + + for _, occupant in room:each_occupant() do + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + local role = occupant.role or "none"; + if broadcast_roles[role] and not old_broadcast_roles[role] then + -- Presence broadcast is now enabled, so announce existing user + room:publicise_occupant_status(occupant, x); + elseif old_broadcast_roles[role] and not broadcast_roles[role] then + -- Presence broadcast is now disabled, so mark existing user as unavailable + room:publicise_occupant_status(occupant, x, nil, nil, nil, nil, true); + end + end + + return true; +end + +module:hook("muc-config-form", function(event) + local values = {}; + for role, value in pairs(get_presence_broadcast(event.room)) do + if value then + values[#values + 1] = role; + end + end + + table.insert(event.form, { + name = "muc#roomconfig_presencebroadcast"; + type = "list-multi"; + label = "Only show participants with roles:"; + value = values; + options = valid_roles; + }); +end, 70-7); + +module:hook("muc-config-submitted/muc#roomconfig_presencebroadcast", function(event) + local broadcast_roles = {}; + for _, role in ipairs(event.value) do + broadcast_roles[role] = true; + end + if set_presence_broadcast(event.room, broadcast_roles) then + event.status_codes["104"] = true; + end +end); + +return { + get = get_presence_broadcast; + set = set_presence_broadcast; +}; diff --git a/plugins/muc/register.lib.lua b/plugins/muc/register.lib.lua index 95ed1a84..f0a15dd4 100644 --- a/plugins/muc/register.lib.lua +++ b/plugins/muc/register.lib.lua @@ -15,8 +15,7 @@ local function get_reserved_nicks(room) end module:log("debug", "Refreshing reserved nicks..."); local reserved_nicks = {}; - for jid in room:each_affiliation() do - local data = room._affiliation_data[jid]; + for jid, _, data in room:each_affiliation() do local nick = data and data.reserved_nickname; module:log("debug", "Refreshed for %s: %s", jid, nick); if nick then @@ -54,7 +53,7 @@ end); local registration_form = dataforms.new { { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#register" }, - { name = "muc#register_roomnick", type = "text-single", label = "Nickname"}, + { name = "muc#register_roomnick", type = "text-single", required = true, label = "Nickname"}, }; local function enforce_nick_policy(event) @@ -67,7 +66,7 @@ local function enforce_nick_policy(event) local reserved_by = get_registered_jid(room, requested_nick); if reserved_by and reserved_by ~= jid_bare(stanza.attr.from) then module:log("debug", "%s attempted to use nick %s reserved by %s", stanza.attr.from, requested_nick, reserved_by); - local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + local reply = st.error_reply(stanza, "cancel", "conflict", nil, room.jid):up(); origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); return true; end @@ -80,7 +79,7 @@ local function enforce_nick_policy(event) event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick; elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then module:log("debug", "Attempt by %s to join as %s, but their reserved nick is %s", stanza.attr.from, requested_nick, nick); - local reply = st.error_reply(stanza, "cancel", "not-acceptable"):up(); + local reply = st.error_reply(stanza, "cancel", "not-acceptable", nil, room.jid):up(); origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); return true; end @@ -104,7 +103,7 @@ local function handle_register_iq(room, origin, stanza) local user_jid = jid_bare(stanza.attr.from) local affiliation = room:get_affiliation(user_jid); if affiliation == "outcast" then - origin.send(st.error_reply(stanza, "auth", "forbidden")); + origin.send(st.error_reply(stanza, "auth", "forbidden", room.jid)); return true; elseif not (affiliation or allow_unaffiliated) then origin.send(st.error_reply(stanza, "auth", "registration-required")); @@ -135,7 +134,19 @@ local function handle_register_iq(room, origin, stanza) return true; end local form_tag = query:get_child("x", "jabber:x:data"); - local reg_data = form_tag and registration_form:data(form_tag); + if not form_tag then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform")); + return true; + end + local form_type, err = dataforms.get_type(form_tag); + if not form_type then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Error with form: "..err)); + return true; + elseif form_type ~= "http://jabber.org/protocol/muc#register" then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form")); + return true; + end + local reg_data = registration_form:data(form_tag); if not reg_data then origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form")); return true; diff --git a/plugins/muc/subject.lib.lua b/plugins/muc/subject.lib.lua index 938abf61..c8b99cc7 100644 --- a/plugins/muc/subject.lib.lua +++ b/plugins/muc/subject.lib.lua @@ -94,6 +94,12 @@ module:hook("muc-occupant-groupchat", function(event) local stanza = event.stanza; local subject = stanza:get_child("subject"); if subject then + if stanza:get_child("body") or stanza:get_child("thread") then + -- Note: A message with a <subject/> and a <body/> or a <subject/> and + -- a <thread/> is a legitimate message, but it SHALL NOT be interpreted + -- as a subject change. + return; + end local room = event.room; local occupant = event.occupant; -- Role check for subject changes diff --git a/plugins/muc/util.lib.lua b/plugins/muc/util.lib.lua index 53a83fae..0877150f 100644 --- a/plugins/muc/util.lib.lua +++ b/plugins/muc/util.lib.lua @@ -41,18 +41,22 @@ function _M.is_kickable_error(stanza) return kickable_error_conditions[cond]; end -local muc_x_filters = { - ["http://jabber.org/protocol/muc"] = true; - ["http://jabber.org/protocol/muc#user"] = true; -} -local function muc_x_filter(tag) - if muc_x_filters[tag.attr.xmlns] then +local filtered_namespaces = module:shared("filtered-namespaces"); +filtered_namespaces["http://jabber.org/protocol/muc"] = true; +filtered_namespaces["http://jabber.org/protocol/muc#user"] = true; + +local function muc_ns_filter(tag) + if filtered_namespaces[tag.attr.xmlns] then return nil; end return tag; end function _M.filter_muc_x(stanza) - return stanza:maptags(muc_x_filter); + return stanza:maptags(muc_ns_filter); +end + +function _M.add_filtered_namespace(xmlns) + filtered_namespaces[xmlns] = true; end function _M.only_with_min_role(role) |