diff options
Diffstat (limited to 'plugins')
56 files changed, 11670 insertions, 227 deletions
diff --git a/plugins/adhoc/adhoc.lib.lua b/plugins/adhoc/adhoc.lib.lua new file mode 100644 index 00000000..ecddcd1d --- /dev/null +++ b/plugins/adhoc/adhoc.lib.lua @@ -0,0 +1,87 @@ +-- Copyright (C) 2009-2010 Florian Zeitz +-- +-- This file is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st, uuid = require "util.stanza", require "util.uuid"; + +local xmlns_cmd = "http://jabber.org/protocol/commands"; + +local states = {} + +local _M = {}; + +local function _cmdtag(desc, status, sessionid, action) + local cmd = st.stanza("command", { xmlns = xmlns_cmd, node = desc.node, status = status }); + if sessionid then cmd.attr.sessionid = sessionid; end + if action then cmd.attr.action = action; end + + return cmd; +end + +function _M.new(name, node, handler, permission) + return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = (permission or "user") }; +end + +function _M.handle_cmd(command, origin, stanza) + local sessionid = stanza.tags[1].attr.sessionid or uuid.generate(); + local dataIn = {}; + dataIn.to = stanza.attr.to; + dataIn.from = stanza.attr.from; + dataIn.action = stanza.tags[1].attr.action or "execute"; + dataIn.form = stanza.tags[1]:child_with_ns("jabber:x:data"); + + local data, state = command:handler(dataIn, states[sessionid]); + states[sessionid] = state; + local stanza = st.reply(stanza); + local cmdtag; + if data.status == "completed" then + states[sessionid] = nil; + cmdtag = command:cmdtag("completed", sessionid); + elseif data.status == "canceled" then + states[sessionid] = nil; + cmdtag = command:cmdtag("canceled", sessionid); + elseif data.status == "error" then + states[sessionid] = nil; + stanza = st.error_reply(stanza, data.error.type, data.error.condition, data.error.message); + origin.send(stanza); + return true; + else + cmdtag = command:cmdtag("executing", sessionid); + data.actions = data.actions or { "complete" }; + end + + for name, content in pairs(data) do + if name == "info" then + cmdtag:tag("note", {type="info"}):text(content):up(); + elseif name == "warn" then + cmdtag:tag("note", {type="warn"}):text(content):up(); + elseif name == "error" then + cmdtag:tag("note", {type="error"}):text(content.message):up(); + elseif name == "actions" then + local actions = st.stanza("actions", { execute = content.default }); + for _, action in ipairs(content) do + if (action == "prev") or (action == "next") or (action == "complete") then + actions:tag(action):up(); + else + module:log("error", "Command %q at node %q provided an invalid action %q", + command.name, command.node, action); + end + end + cmdtag:add_child(actions); + elseif name == "form" then + cmdtag:add_child((content.layout or content):form(content.values)); + elseif name == "result" then + cmdtag:add_child((content.layout or content):form(content.values, "result")); + elseif name == "other" then + cmdtag:add_child(content); + end + end + stanza:add_child(cmdtag); + origin.send(stanza); + + return true; +end + +return _M; diff --git a/plugins/adhoc/mod_adhoc.lua b/plugins/adhoc/mod_adhoc.lua new file mode 100644 index 00000000..69b2c8da --- /dev/null +++ b/plugins/adhoc/mod_adhoc.lua @@ -0,0 +1,103 @@ +-- Copyright (C) 2009 Thilo Cestonaro +-- Copyright (C) 2009-2011 Florian Zeitz +-- +-- This file is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local is_admin = require "core.usermanager".is_admin; +local adhoc_handle_cmd = module:require "adhoc".handle_cmd; +local xmlns_cmd = "http://jabber.org/protocol/commands"; +local xmlns_disco = "http://jabber.org/protocol/disco"; +local commands = {}; + +module:add_feature(xmlns_cmd); + +module:hook("iq/host/"..xmlns_disco.."#info:query", function (event) + local origin, stanza = event.origin, event.stanza; + local node = stanza.tags[1].attr.node; + if stanza.attr.type == "get" and node then + if commands[node] then + local privileged = is_admin(stanza.attr.from, stanza.attr.to); + if (commands[node].permission == "admin" and privileged) + or (commands[node].permission == "user") then + reply = st.reply(stanza); + reply:tag("query", { xmlns = xmlns_disco.."#info", + node = node }); + reply:tag("identity", { name = commands[node].name, + category = "automation", type = "command-node" }):up(); + reply:tag("feature", { var = xmlns_cmd }):up(); + reply:tag("feature", { var = "jabber:x:data" }):up(); + else + reply = st.error_reply(stanza, "auth", "forbidden", "This item is not available to you"); + end + origin.send(reply); + return true; + elseif node == xmlns_cmd then + reply = st.reply(stanza); + reply:tag("query", { xmlns = xmlns_disco.."#info", + node = node }); + reply:tag("identity", { name = "Ad-Hoc Commands", + category = "automation", type = "command-list" }):up(); + origin.send(reply); + return true; + + end + end +end); + +module:hook("iq/host/"..xmlns_disco.."#items:query", function (event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "get" and stanza.tags[1].attr.node + and stanza.tags[1].attr.node == xmlns_cmd then + local admin = is_admin(stanza.attr.from, stanza.attr.to); + local global_admin = is_admin(stanza.attr.from); + reply = st.reply(stanza); + reply:tag("query", { xmlns = xmlns_disco.."#items", + node = xmlns_cmd }); + for node, command in pairs(commands) do + if (command.permission == "admin" and admin) + or (command.permission == "global_admin" and global_admin) + or (command.permission == "user") then + reply:tag("item", { name = command.name, + node = node, jid = module:get_host() }); + reply:up(); + end + end + origin.send(reply); + return true; + end +end, 500); + +module:hook("iq/host/"..xmlns_cmd..":command", function (event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "set" then + local node = stanza.tags[1].attr.node + if commands[node] then + local admin = is_admin(stanza.attr.from, stanza.attr.to); + local global_admin = is_admin(stanza.attr.from); + if (commands[node].permission == "admin" and not admin) + or (commands[node].permission == "global_admin" and not global_admin) then + origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up() + :add_child(commands[node]:cmdtag("canceled") + :tag("note", {type="error"}):text("You don't have permission to execute this command"))); + return true + end + -- User has permission now execute the command + return adhoc_handle_cmd(commands[node], origin, stanza); + end + end +end, 500); + +local function adhoc_added(event) + local item = event.item; + commands[item.node] = item; +end + +local function adhoc_removed(event) + commands[event.item.node] = nil; +end + +module:handle_items("adhoc", adhoc_added, adhoc_removed); +module:handle_items("adhoc-provider", adhoc_added, adhoc_removed); diff --git a/plugins/mod_admin_adhoc.lua b/plugins/mod_admin_adhoc.lua new file mode 100644 index 00000000..31c4bde4 --- /dev/null +++ b/plugins/mod_admin_adhoc.lua @@ -0,0 +1,759 @@ +-- Copyright (C) 2009-2011 Florian Zeitz +-- +-- This file is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local _G = _G; + +local prosody = _G.prosody; +local hosts = prosody.hosts; +local t_concat = table.concat; + +local module_host = module:get_host(); + +local keys = require "util.iterators".keys; +local usermanager_user_exists = require "core.usermanager".user_exists; +local usermanager_create_user = require "core.usermanager".create_user; +local usermanager_delete_user = require "core.usermanager".delete_user; +local usermanager_get_password = require "core.usermanager".get_password; +local usermanager_set_password = require "core.usermanager".set_password; +local hostmanager_activate = require "core.hostmanager".activate; +local hostmanager_deactivate = require "core.hostmanager".deactivate; +local rm_load_roster = require "core.rostermanager".load_roster; +local st, jid = require "util.stanza", require "util.jid"; +local timer_add_task = require "util.timer".add_task; +local dataforms_new = require "util.dataforms".new; +local array = require "util.array"; +local modulemanager = require "modulemanager"; +local core_post_stanza = prosody.core_post_stanza; +local adhoc_simple = require "util.adhoc".new_simple_form; +local adhoc_initial = require "util.adhoc".new_initial_data_form; + +module:depends("adhoc"); +local adhoc_new = module:require "adhoc".new; + +local function generate_error_message(errors) + local errmsg = {}; + for name, err in pairs(errors) do + errmsg[#errmsg + 1] = name .. ": " .. err; + end + return { status = "completed", error = { message = t_concat(errmsg, "\n") } }; +end + +-- Adding a new user +local add_user_layout = dataforms_new{ + title = "Adding a User"; + instructions = "Fill out this form to add a user."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjid", type = "jid-single", required = true, label = "The Jabber ID for the account to be added" }; + { name = "password", type = "text-private", label = "The password for this account" }; + { name = "password-verify", type = "text-private", label = "Retype password" }; +}; + +local add_user_command_handler = adhoc_simple(add_user_layout, function(fields, err) + if err then + return generate_error_message(err); + end + local username, host, resource = 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 + if (fields["password"] == fields["password-verify"]) and username and host then + if usermanager_user_exists(username, host) then + return { status = "completed", error = { message = "Account already exists" } }; + else + if usermanager_create_user(username, fields.password, host) then + module:log("info", "Created new account %s@%s", username, host); + return { status = "completed", info = "Account successfully created" }; + else + return { status = "completed", error = { message = "Failed to write data to disk" } }; + end + end + else + module:log("debug", "Invalid data, password mismatch or empty username while creating account for %s", fields.accountjid or "<nil>"); + return { status = "completed", error = { message = "Invalid data.\nPassword mismatch, or empty username" } }; + end +end); + +-- Changing a user's password +local change_user_password_layout = dataforms_new{ + title = "Changing a User Password"; + instructions = "Fill out this form to change a user's password."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjid", type = "jid-single", required = true, label = "The Jabber ID for this account" }; + { name = "password", type = "text-private", required = true, label = "The password for this account" }; +}; + +local change_user_password_command_handler = adhoc_simple(change_user_password_layout, function(fields, err) + if err then + return generate_error_message(err); + end + local username, host, resource = jid.split(fields.accountjid); + if module_host ~= host then + return { status = "completed", error = { message = "Trying to change the password of a user on " .. host .. " but command was sent to " .. module_host}}; + end + if usermanager_user_exists(username, host) and usermanager_set_password(username, fields.password, host) then + return { status = "completed", info = "Password successfully changed" }; + else + return { status = "completed", error = { message = "User does not exist" } }; + end +end); + +-- Reloading the config +local function config_reload_handler(self, data, state) + local ok, err = prosody.reload_config(); + if ok then + return { status = "completed", info = "Configuration reloaded (modules may need to be reloaded for this to have an effect)" }; + else + return { status = "completed", error = { message = "Failed to reload config: " .. tostring(err) } }; + end +end + +-- Deleting a user's account +local delete_user_layout = dataforms_new{ + title = "Deleting a User"; + instructions = "Fill out this form to delete a user."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjids", type = "jid-multi", label = "The Jabber ID(s) to delete" }; +}; + +local delete_user_command_handler = adhoc_simple(delete_user_layout, function(fields, err) + if err then + return generate_error_message(err); + end + local failed = {}; + local succeeded = {}; + for _, aJID in ipairs(fields.accountjids) do + local username, host, resource = jid.split(aJID); + 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; + else + module:log("debug", "Tried to delete non-existant user %s", aJID); + failed[#failed+1] = aJID; + end + end + return {status = "completed", info = (#succeeded ~= 0 and + "The following accounts were successfully deleted:\n"..t_concat(succeeded, "\n").."\n" or "").. + (#failed ~= 0 and + "The following accounts could not be deleted:\n"..t_concat(failed, "\n") or "") }; +end); + +-- Ending a user's session +local function disconnect_user(match_jid) + local node, hostname, givenResource = jid.split(match_jid); + local host = hosts[hostname]; + local sessions = host.sessions[node] and host.sessions[node].sessions; + for resource, session in pairs(sessions or {}) do + if not givenResource or (resource == givenResource) then + module:log("debug", "Disconnecting %s@%s/%s", node, hostname, resource); + session:close(); + end + end + return true; +end + +local end_user_session_layout = dataforms_new{ + title = "Ending a User Session"; + instructions = "Fill out this form to end a user's session."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjids", type = "jid-multi", label = "The Jabber ID(s) for which to end sessions" }; +}; + +local end_user_session_handler = adhoc_simple(end_user_session_layout, function(fields, err) + if err then + return generate_error_message(err); + end + local failed = {}; + local succeeded = {}; + for _, aJID in ipairs(fields.accountjids) do + local username, host, resource = jid.split(aJID); + if (host == module_host) and usermanager_user_exists(username, host) and disconnect_user(aJID) then + succeeded[#succeeded+1] = aJID; + else + failed[#failed+1] = aJID; + end + end + return {status = "completed", info = (#succeeded ~= 0 and + "The following accounts were successfully disconnected:\n"..t_concat(succeeded, "\n").."\n" or "").. + (#failed ~= 0 and + "The following accounts could not be disconnected:\n"..t_concat(failed, "\n") or "") }; +end); + +-- Getting a user's password +local get_user_password_layout = dataforms_new{ + title = "Getting User's Password"; + instructions = "Fill out this form to get a user's password."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjid", type = "jid-single", required = true, label = "The Jabber ID for which to retrieve the password" }; +}; + +local get_user_password_result_layout = dataforms_new{ + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjid", type = "jid-single", label = "JID" }; + { name = "password", type = "text-single", label = "Password" }; +}; + +local get_user_password_handler = adhoc_simple(get_user_password_layout, function(fields, err) + if err then + return generate_error_message(err); + end + local user, host, resource = jid.split(fields.accountjid); + local accountjid = ""; + local password = ""; + if host ~= module_host then + return { status = "completed", error = { message = "Tried to get password for a user on " .. host .. " but command was sent to " .. module_host } }; + elseif usermanager_user_exists(user, host) then + accountjid = fields.accountjid; + password = usermanager_get_password(user, host); + else + return { status = "completed", error = { message = "User does not exist" } }; + end + return { status = "completed", result = { layout = get_user_password_result_layout, values = {accountjid = accountjid, password = password} } }; +end); + +-- Getting a user's roster +local get_user_roster_layout = dataforms_new{ + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjid", type = "jid-single", required = true, label = "The Jabber ID for which to retrieve the roster" }; +}; + +local get_user_roster_result_layout = dataforms_new{ + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjid", type = "jid-single", label = "This is the roster for" }; + { name = "roster", type = "text-multi", label = "Roster XML" }; +}; + +local get_user_roster_handler = adhoc_simple(get_user_roster_layout, function(fields, err) + if err then + return generate_error_message(err); + end + + local user, host, resource = 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 + return { status = "completed", error = { message = "User does not exist" } }; + end + local roster = rm_load_roster(user, host); + + local query = st.stanza("query", { xmlns = "jabber:iq:roster" }); + for jid in pairs(roster) do + if jid ~= "pending" and jid then + query:tag("item", { + jid = jid, + subscription = roster[jid].subscription, + ask = roster[jid].ask, + name = roster[jid].name, + }); + for group in pairs(roster[jid].groups) do + query:tag("group"):text(group):up(); + end + query:up(); + end + end + + local query_text = tostring(query):gsub("><", ">\n<"); + + local result = get_user_roster_result_layout:form({ accountjid = user.."@"..host, roster = query_text }, "result"); + result:add_child(query); + return { status = "completed", other = result }; +end); + +-- Getting user statistics +local get_user_stats_layout = dataforms_new{ + title = "Get User Statistics"; + instructions = "Fill out this form to gather user statistics."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "accountjid", type = "jid-single", required = true, label = "The Jabber ID for statistics" }; +}; + +local get_user_stats_result_layout = dataforms_new{ + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "ipaddresses", type = "text-multi", label = "IP Addresses" }; + { name = "rostersize", type = "text-single", label = "Roster size" }; + { name = "onlineresources", type = "text-multi", label = "Online Resources" }; +}; + +local get_user_stats_handler = adhoc_simple(get_user_stats_layout, function(fields, err) + if err then + return generate_error_message(err); + end + + local user, host, resource = jid.split(fields.accountjid); + if host ~= module_host then + return { status = "completed", error = { message = "Tried to get stats for a user on " .. host .. " but command was sent to " .. module_host } }; + elseif not usermanager_user_exists(user, host) then + return { status = "completed", error = { message = "User does not exist" } }; + end + local roster = rm_load_roster(user, host); + local rostersize = 0; + local IPs = ""; + local resources = ""; + for jid in pairs(roster) do + if jid ~= "pending" and jid then + rostersize = rostersize + 1; + end + end + for resource, session in pairs((hosts[host].sessions[user] and hosts[host].sessions[user].sessions) or {}) do + resources = resources .. "\n" .. resource; + IPs = IPs .. "\n" .. session.ip; + end + return { status = "completed", result = {layout = get_user_stats_result_layout, values = {ipaddresses = IPs, rostersize = tostring(rostersize), + onlineresources = resources}} }; +end); + +-- Getting a list of online users +local get_online_users_layout = dataforms_new{ + title = "Getting List of Online Users"; + instructions = "How many users should be returned at most?"; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "max_items", type = "list-single", label = "Maximum number of users", + value = { "25", "50", "75", "100", "150", "200", "all" } }; + { name = "details", type = "boolean", label = "Show details" }; +}; + +local get_online_users_result_layout = dataforms_new{ + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "onlineuserjids", type = "text-multi", label = "The list of all online users" }; +}; + +local get_online_users_command_handler = adhoc_simple(get_online_users_layout, function(fields, err) + if err then + return generate_error_message(err); + end + + local max_items = nil + if fields.max_items ~= "all" then + max_items = tonumber(fields.max_items); + end + local count = 0; + local users = {}; + for username, user in pairs(hosts[module_host].sessions or {}) do + if (max_items ~= nil) and (count >= max_items) then + break; + end + users[#users+1] = username.."@"..module_host; + count = count + 1; + if fields.details then + for resource, session in pairs(user.sessions or {}) do + local status, priority = "unavailable", tostring(session.priority or "-"); + if session.presence then + status = session.presence:child_with_name("show"); + if status then + status = status:get_text() or "[invalid!]"; + else + status = "available"; + end + end + users[#users+1] = " - "..resource..": "..status.."("..priority..")"; + end + end + end + return { status = "completed", result = {layout = get_online_users_result_layout, values = {onlineuserjids=t_concat(users, "\n")}} }; +end); + +-- Getting a list of loaded modules +local list_modules_result = dataforms_new { + title = "List of loaded modules"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/modules#list" }; + { name = "modules", type = "text-multi", label = "The following modules are loaded:" }; +}; + +local function list_modules_handler(self, data, state) + local modules = array.collect(keys(hosts[module_host].modules)):sort():concat("\n"); + return { status = "completed", result = { layout = list_modules_result; values = { modules = modules } } }; +end + +-- Loading a module +local load_module_layout = dataforms_new { + title = "Load module"; + instructions = "Specify the module to be loaded"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/modules#load" }; + { name = "module", type = "text-single", required = true, label = "Module to be loaded:"}; +}; + +local load_module_handler = adhoc_simple(load_module_layout, function(fields, err) + if err then + return generate_error_message(err); + end + if modulemanager.is_loaded(module_host, fields.module) then + return { status = "completed", info = "Module already loaded" }; + end + local ok, err = modulemanager.load(module_host, fields.module); + if ok then + return { status = "completed", info = 'Module "'..fields.module..'" successfully loaded on host "'..module_host..'".' }; + else + return { status = "completed", error = { message = 'Failed to load module "'..fields.module..'" on host "'..module_host.. + '". Error was: "'..tostring(err or "<unspecified>")..'"' } }; + end +end); + +-- Globally loading a module +local globally_load_module_layout = dataforms_new { + title = "Globally load module"; + instructions = "Specify the module to be loaded on all hosts"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/modules#global-load" }; + { name = "module", type = "text-single", required = true, label = "Module to globally load:"}; +}; + +local globally_load_module_handler = adhoc_simple(globally_load_module_layout, function(fields, err) + local ok_list, err_list = {}, {}; + + if err then + return generate_error_message(err); + end + + local ok, err = modulemanager.load(module_host, fields.module); + if ok then + ok_list[#ok_list + 1] = module_host; + else + err_list[#err_list + 1] = module_host .. " (Error: " .. tostring(err) .. ")"; + end + + -- Is this a global module? + if modulemanager.is_loaded("*", fields.module) and not modulemanager.is_loaded(module_host, fields.module) then + return { status = "completed", info = 'Global module '..fields.module..' loaded.' }; + end + + -- This is either a shared or "normal" module, load it on all other hosts + for host_name, host in pairs(hosts) do + if host_name ~= module_host and host.type == "local" then + local ok, err = modulemanager.load(host_name, fields.module); + if ok then + ok_list[#ok_list + 1] = host_name; + else + err_list[#err_list + 1] = host_name .. " (Error: " .. tostring(err) .. ")"; + end + end + end + + local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully loaded onto the hosts:\n"..t_concat(ok_list, "\n")) or "") + .. ((#ok_list > 0 and #err_list > 0) and "\n" or "") .. + (#err_list > 0 and ("Failed to load the module "..fields.module.." onto the hosts:\n"..t_concat(err_list, "\n")) or ""); + return { status = "completed", info = info }; +end); + +-- Reloading modules +local reload_modules_layout = dataforms_new { + title = "Reload modules"; + instructions = "Select the modules to be reloaded"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/modules#reload" }; + { name = "modules", type = "list-multi", required = true, label = "Modules to be reloaded:"}; +}; + +local reload_modules_handler = adhoc_initial(reload_modules_layout, function() + return { modules = array.collect(keys(hosts[module_host].modules)):sort() }; +end, function(fields, err) + if err then + return generate_error_message(err); + end + local ok_list, err_list = {}, {}; + for _, module in ipairs(fields.modules) do + local ok, err = modulemanager.reload(module_host, module); + if ok then + ok_list[#ok_list + 1] = module; + else + err_list[#err_list + 1] = module .. "(Error: " .. tostring(err) .. ")"; + end + end + local info = (#ok_list > 0 and ("The following modules were successfully reloaded on host "..module_host..":\n"..t_concat(ok_list, "\n")) or "") + .. ((#ok_list > 0 and #err_list > 0) and "\n" or "") .. + (#err_list > 0 and ("Failed to reload the following modules on host "..module_host..":\n"..t_concat(err_list, "\n")) or ""); + return { status = "completed", info = info }; +end); + +-- Globally reloading a module +local globally_reload_module_layout = dataforms_new { + title = "Globally reload module"; + instructions = "Specify the module to reload on all hosts"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/modules#global-reload" }; + { name = "module", type = "list-single", required = true, label = "Module to globally reload:"}; +}; + +local globally_reload_module_handler = adhoc_initial(globally_reload_module_layout, function() + local loaded_modules = array(keys(modulemanager.get_modules("*"))); + for _, host in pairs(hosts) do + loaded_modules:append(array(keys(host.modules))); + end + loaded_modules = array(keys(set.new(loaded_modules):items())):sort(); + return { module = loaded_modules }; +end, function(fields, err) + local is_global = false; + + if err then + return generate_error_message(err); + end + + if modulemanager.is_loaded("*", fields.module) then + local ok, err = modulemanager.reload("*", fields.module); + if not ok then + return { status = "completed", info = 'Global module '..fields.module..' failed to reload: '..err }; + end + is_global = true; + end + + local ok_list, err_list = {}, {}; + for host_name, host in pairs(hosts) do + if modulemanager.is_loaded(host_name, fields.module) then + local ok, err = modulemanager.reload(host_name, fields.module); + if ok then + ok_list[#ok_list + 1] = host_name; + else + err_list[#err_list + 1] = host_name .. " (Error: " .. tostring(err) .. ")"; + end + end + end + + if #ok_list == 0 and #err_list == 0 then + if is_global then + return { status = "completed", info = 'Successfully reloaded global module '..fields.module }; + else + return { status = "completed", info = 'Module '..fields.module..' not loaded on any host.' }; + end + end + + local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully reloaded on the hosts:\n"..t_concat(ok_list, "\n")) or "") + .. ((#ok_list > 0 and #err_list > 0) and "\n" or "") .. + (#err_list > 0 and ("Failed to reload the module "..fields.module.." on the hosts:\n"..t_concat(err_list, "\n")) or ""); + return { status = "completed", info = info }; +end); + +local function send_to_online(message, server) + if server then + sessions = { [server] = hosts[server] }; + else + sessions = hosts; + end + + local c = 0; + for domain, session in pairs(sessions) do + for user in pairs(session.sessions or {}) do + c = c + 1; + message.attr.from = domain; + message.attr.to = user.."@"..domain; + core_post_stanza(session, message); + end + end + + return c; +end + +-- Shutting down the service +local shut_down_service_layout = dataforms_new{ + title = "Shutting Down the Service"; + instructions = "Fill out this form to shut down the service."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "delay", type = "list-single", label = "Time delay before shutting down", + value = { {label = "30 seconds", value = "30"}, + {label = "60 seconds", value = "60"}, + {label = "90 seconds", value = "90"}, + {label = "2 minutes", value = "120"}, + {label = "3 minutes", value = "180"}, + {label = "4 minutes", value = "240"}, + {label = "5 minutes", value = "300"}, + }; + }; + { name = "announcement", type = "text-multi", label = "Announcement" }; +}; + +local shut_down_service_handler = adhoc_simple(shut_down_service_layout, function(fields, err) + if err then + return generate_error_message(err); + end + + if fields.announcement and #fields.announcement > 0 then + local message = st.message({type = "headline"}, fields.announcement):up() + :tag("subject"):text("Server is shutting down"); + send_to_online(message); + end + + timer_add_task(tonumber(fields.delay or "5"), function(time) prosody.shutdown("Shutdown by adhoc command") end); + + return { status = "completed", info = "Server is about to shut down" }; +end); + +-- Unloading modules +local unload_modules_layout = dataforms_new { + title = "Unload modules"; + instructions = "Select the modules to be unloaded"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/modules#unload" }; + { name = "modules", type = "list-multi", required = true, label = "Modules to be unloaded:"}; +}; + +local unload_modules_handler = adhoc_initial(unload_modules_layout, function() + return { modules = array.collect(keys(hosts[module_host].modules)):sort() }; +end, function(fields, err) + if err then + return generate_error_message(err); + end + local ok_list, err_list = {}, {}; + for _, module in ipairs(fields.modules) do + local ok, err = modulemanager.unload(module_host, module); + if ok then + ok_list[#ok_list + 1] = module; + else + err_list[#err_list + 1] = module .. "(Error: " .. tostring(err) .. ")"; + end + end + local info = (#ok_list > 0 and ("The following modules were successfully unloaded on host "..module_host..":\n"..t_concat(ok_list, "\n")) or "") + .. ((#ok_list > 0 and #err_list > 0) and "\n" or "") .. + (#err_list > 0 and ("Failed to unload the following modules on host "..module_host..":\n"..t_concat(err_list, "\n")) or ""); + return { status = "completed", info = info }; +end); + +-- Globally unloading a module +local globally_unload_module_layout = dataforms_new { + title = "Globally unload module"; + instructions = "Specify a module to unload on all hosts"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/modules#global-unload" }; + { name = "module", type = "list-single", required = true, label = "Module to globally unload:"}; +}; + +local globally_unload_module_handler = adhoc_initial(globally_unload_module_layout, function() + local loaded_modules = array(keys(modulemanager.get_modules("*"))); + for _, host in pairs(hosts) do + loaded_modules:append(array(keys(host.modules))); + end + loaded_modules = array(keys(set.new(loaded_modules):items())):sort(); + return { module = loaded_modules }; +end, function(fields, err) + local is_global = false; + if err then + return generate_error_message(err); + end + + if modulemanager.is_loaded("*", fields.module) then + local ok, err = modulemanager.unload("*", fields.module); + if not ok then + return { status = "completed", info = 'Global module '..fields.module..' failed to unload: '..err }; + end + is_global = true; + end + + local ok_list, err_list = {}, {}; + for host_name, host in pairs(hosts) do + if modulemanager.is_loaded(host_name, fields.module) then + local ok, err = modulemanager.unload(host_name, fields.module); + if ok then + ok_list[#ok_list + 1] = host_name; + else + err_list[#err_list + 1] = host_name .. " (Error: " .. tostring(err) .. ")"; + end + end + end + + if #ok_list == 0 and #err_list == 0 then + if is_global then + return { status = "completed", info = 'Successfully unloaded global module '..fields.module }; + else + return { status = "completed", info = 'Module '..fields.module..' not loaded on any host.' }; + end + end + + local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully unloaded on the hosts:\n"..t_concat(ok_list, "\n")) or "") + .. ((#ok_list > 0 and #err_list > 0) and "\n" or "") .. + (#err_list > 0 and ("Failed to unload the module "..fields.module.." on the hosts:\n"..t_concat(err_list, "\n")) or ""); + return { status = "completed", info = info }; +end); + +-- Activating a host +local activate_host_layout = dataforms_new { + title = "Activate host"; + instructions = ""; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/hosts#activate" }; + { name = "host", type = "text-single", required = true, label = "Host:"}; +}; + +local activate_host_handler = adhoc_simple(activate_host_layout, function(fields, err) + if err then + return generate_error_message(err); + end + local ok, err = hostmanager_activate(fields.host); + + if ok then + return { status = "completed", info = fields.host .. " activated" }; + else + return { status = "canceled", error = err } + end +end); + +-- Deactivating a host +local deactivate_host_layout = dataforms_new { + title = "Deactivate host"; + instructions = ""; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/hosts#activate" }; + { name = "host", type = "text-single", required = true, label = "Host:"}; +}; + +local deactivate_host_handler = adhoc_simple(deactivate_host_layout, function(fields, err) + if err then + return generate_error_message(err); + end + local ok, err = hostmanager_deactivate(fields.host); + + if ok then + return { status = "completed", info = fields.host .. " deactivated" }; + else + return { status = "canceled", error = err } + end +end); + + +local add_user_desc = adhoc_new("Add User", "http://jabber.org/protocol/admin#add-user", add_user_command_handler, "admin"); +local change_user_password_desc = adhoc_new("Change User Password", "http://jabber.org/protocol/admin#change-user-password", change_user_password_command_handler, "admin"); +local config_reload_desc = adhoc_new("Reload configuration", "http://prosody.im/protocol/config#reload", config_reload_handler, "global_admin"); +local delete_user_desc = adhoc_new("Delete User", "http://jabber.org/protocol/admin#delete-user", delete_user_command_handler, "admin"); +local end_user_session_desc = adhoc_new("End User Session", "http://jabber.org/protocol/admin#end-user-session", end_user_session_handler, "admin"); +local get_user_password_desc = adhoc_new("Get User Password", "http://jabber.org/protocol/admin#get-user-password", get_user_password_handler, "admin"); +local get_user_roster_desc = adhoc_new("Get User Roster","http://jabber.org/protocol/admin#get-user-roster", get_user_roster_handler, "admin"); +local get_user_stats_desc = adhoc_new("Get User Statistics","http://jabber.org/protocol/admin#user-stats", get_user_stats_handler, "admin"); +local get_online_users_desc = adhoc_new("Get List of Online Users", "http://jabber.org/protocol/admin#get-online-users", get_online_users_command_handler, "admin"); +local list_modules_desc = adhoc_new("List loaded modules", "http://prosody.im/protocol/modules#list", list_modules_handler, "admin"); +local load_module_desc = adhoc_new("Load module", "http://prosody.im/protocol/modules#load", load_module_handler, "admin"); +local globally_load_module_desc = adhoc_new("Globally load module", "http://prosody.im/protocol/modules#global-load", globally_load_module_handler, "global_admin"); +local reload_modules_desc = adhoc_new("Reload modules", "http://prosody.im/protocol/modules#reload", reload_modules_handler, "admin"); +local globally_reload_module_desc = adhoc_new("Globally reload module", "http://prosody.im/protocol/modules#global-reload", globally_reload_module_handler, "global_admin"); +local shut_down_service_desc = adhoc_new("Shut Down Service", "http://jabber.org/protocol/admin#shutdown", shut_down_service_handler, "global_admin"); +local unload_modules_desc = adhoc_new("Unload modules", "http://prosody.im/protocol/modules#unload", unload_modules_handler, "admin"); +local globally_unload_module_desc = adhoc_new("Globally unload module", "http://prosody.im/protocol/modules#global-unload", globally_unload_module_handler, "global_admin"); +local activate_host_desc = adhoc_new("Activate host", "http://prosody.im/protocol/hosts#activate", activate_host_handler, "global_admin"); +local deactivate_host_desc = adhoc_new("Deactivate host", "http://prosody.im/protocol/hosts#deactivate", deactivate_host_handler, "global_admin"); + +module:provides("adhoc", add_user_desc); +module:provides("adhoc", change_user_password_desc); +module:provides("adhoc", config_reload_desc); +module:provides("adhoc", delete_user_desc); +module:provides("adhoc", end_user_session_desc); +module:provides("adhoc", get_user_password_desc); +module:provides("adhoc", get_user_roster_desc); +module:provides("adhoc", get_user_stats_desc); +module:provides("adhoc", get_online_users_desc); +module:provides("adhoc", list_modules_desc); +module:provides("adhoc", load_module_desc); +module:provides("adhoc", globally_load_module_desc); +module:provides("adhoc", reload_modules_desc); +module:provides("adhoc", globally_reload_module_desc); +module:provides("adhoc", shut_down_service_desc); +module:provides("adhoc", unload_modules_desc); +module:provides("adhoc", globally_unload_module_desc); +module:provides("adhoc", activate_host_desc); +module:provides("adhoc", deactivate_host_desc); diff --git a/plugins/mod_admin_telnet.lua b/plugins/mod_admin_telnet.lua new file mode 100644 index 00000000..2622a5f9 --- /dev/null +++ b/plugins/mod_admin_telnet.lua @@ -0,0 +1,1038 @@ +-- 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:set_global(); + +local hostmanager = require "core.hostmanager"; +local modulemanager = require "core.modulemanager"; +local s2smanager = require "core.s2smanager"; +local portmanager = require "core.portmanager"; + +local _G = _G; + +local prosody = _G.prosody; +local hosts = prosody.hosts; +local incoming_s2s = prosody.incoming_s2s; + +local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" }; + +local iterators = require "util.iterators"; +local keys, values = iterators.keys, iterators.values; +local jid = require "util.jid"; +local jid_bare, jid_split = jid.bare, jid.split; +local set, array = require "util.set", require "util.array"; +local cert_verify_identity = require "util.x509".verify_identity; +local envload = require "util.envload".envload; +local envloadfile = require "util.envload".envloadfile; + +local commands = module:shared("commands") +local def_env = module:shared("env"); +local default_env_mt = { __index = def_env }; +local core_post_stanza = prosody.core_post_stanza; + +local function redirect_output(_G, session) + local env = setmetatable({ print = session.print }, { __index = function (t, k) return rawget(_G, k); end }); + env.dofile = function(name) + local f, err = envloadfile(name, env); + if not f then return f, err; end + return f(); + end; + return env; +end + +console = {}; + +function console:new_session(conn) + local w = function(s) conn:write(s:gsub("\n", "\r\n")); end; + local session = { conn = conn; + send = function (t) w(tostring(t)); end; + print = function (...) + local t = {}; + for i=1,select("#", ...) do + t[i] = tostring(select(i, ...)); + end + w("| "..table.concat(t, "\t").."\n"); + end; + disconnect = function () conn:close(); end; + }; + session.env = setmetatable({}, default_env_mt); + + -- Load up environment with helper objects + for name, t in pairs(def_env) do + if type(t) == "table" then + session.env[name] = setmetatable({ session = session }, { __index = t }); + end + end + + return session; +end + +function console:process_line(session, line) + local useglobalenv; + + if line:match("^>") then + line = line:gsub("^>", ""); + useglobalenv = true; + elseif line == "\004" then + commands["bye"](session, line); + return; + else + local command = line:match("^%w+") or line:match("%p"); + if commands[command] then + commands[command](session, line); + return; + end + end + + session.env._ = line; + + local chunkname = "=console"; + local env = (useglobalenv and redirect_output(_G, session)) or session.env or nil + local chunk, err = envload("return "..line, chunkname, env); + if not chunk then + chunk, err = envload(line, chunkname, env); + if not chunk then + err = err:gsub("^%[string .-%]:%d+: ", ""); + err = err:gsub("^:%d+: ", ""); + err = err:gsub("'<eof>'", "the end of the line"); + session.print("Sorry, I couldn't understand that... "..err); + return; + end + end + + 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 + + if not message then + session.print("Result: "..tostring(taskok)); + return; + elseif (not taskok) and message then + session.print("Command completed with a problem"); + session.print("Message: "..tostring(message)); + return; + end + + session.print("OK: "..tostring(message)); +end + +local sessions = {}; + +function console_listener.onconnect(conn) + -- Handle new connection + local session = console:new_session(conn); + sessions[conn] = session; + printbanner(session); + session.send(string.char(0)); +end + +function console_listener.onincoming(conn, data) + local session = sessions[conn]; + + local partial = session.partial_data; + if partial then + data = partial..data; + end + + 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)); + end + session.partial_data = data:match("[^\n]+$"); +end + +function console_listener.ondisconnect(conn, err) + local session = sessions[conn]; + if session then + session.disconnect(); + sessions[conn] = nil; + end +end + +-- Console commands -- +-- These are simple commands, not valid standalone in Lua + +function commands.bye(session) + session.print("See you! :)"); + session.closed = true; + session.disconnect(); +end +commands.quit, commands.exit = commands.bye, commands.bye; + +commands["!"] = function (session, data) + if data:match("^!!") and session.env._ then + session.print("!> "..session.env._); + return console_listener.onincoming(session.conn, session.env._); + end + local old, new = data:match("^!(.-[^\\])!(.-)!$"); + if old and new then + local ok, res = pcall(string.gsub, session.env._, old, new); + if not ok then + session.print(res) + return; + end + session.print("!> "..res); + return console_listener.onincoming(session.conn, res); + end + session.print("Sorry, not sure what you want"); +end + + +function commands.help(session, data) + local print = session.print; + local section = data:match("^help (%w+)"); + if not section then + print [[Commands are divided into multiple sections. For help on a particular section, ]] + print [[type: help SECTION (for example, 'help c2s'). Sections are: ]] + print [[]] + print [[c2s - Commands to manage local client-to-server sessions]] + print [[s2s - Commands to manage sessions between this server and others]] + print [[module - Commands to load/reload/unload modules/plugins]] + print [[host - Commands to activate, deactivate and list virtual hosts]] + print [[user - Commands to create and delete users, and change their passwords]] + print [[server - Uptime, version, shutting down, etc.]] + print [[port - Commands to manage ports the server is listening on]] + print [[config - Reloading the configuration, etc.]] + print [[console - Help regarding the console itself]] + elseif section == "c2s" then + print [[c2s:show(jid) - Show all client sessions with the specified JID (or all if no JID given)]] + print [[c2s:show_insecure() - Show all unencrypted client connections]] + print [[c2s:show_secure() - Show all encrypted client connections]] + print [[c2s:close(jid) - Close all sessions for the specified JID]] + elseif section == "s2s" then + print [[s2s:show(domain) - Show all s2s connections for the given domain (or all if no domain given)]] + print [[s2s:close(from, to) - Close a connection from one domain to another]] + print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]] + elseif section == "module" then + print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]] + print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]] + print [[module:unload(module, host) - The same, but just unloads the module from memory]] + print [[module:list(host) - List the modules loaded on the specified host]] + elseif section == "host" then + print [[host:activate(hostname) - Activates the specified host]] + print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]] + print [[host:list() - List the currently-activated hosts]] + elseif section == "user" then + print [[user:create(jid, password) - Create the specified user account]] + print [[user:password(jid, password) - Set the password for the specified user account]] + print [[user:delete(jid) - Permanently remove the specified user account]] + print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]] + elseif section == "server" then + print [[server:version() - Show the server's version number]] + print [[server:uptime() - Show how long the server has been running]] + print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]] + elseif section == "port" then + print [[port:list() - Lists all network ports prosody currently listens on]] + print [[port:close(port, interface) - Close a port]] + elseif section == "config" then + print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]] + elseif section == "console" then + print [[Hey! Welcome to Prosody's admin console.]] + print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]] + print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]] + print [[so you may have trouble using the arrow keys, etc. depending on your system.]] + print [[]] + print [[For now we offer a couple of handy shortcuts:]] + print [[!! - Repeat the last command]] + print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']] + print [[]] + print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]] + print [[you can prefix a command with > to escape the console sandbox, and access everything in]] + print [[the running server. Great fun, but be careful not to break anything :)]] + end + print [[]] +end + +-- Session environment -- +-- Anything in def_env will be accessible within the session as a global variable + +def_env.server = {}; + +function def_env.server:insane_reload() + prosody.unlock_globals(); + dofile "prosody" + prosody = _G.prosody; + return true, "Server reloaded"; +end + +function def_env.server:version() + return true, tostring(prosody.version or "unknown"); +end + +function def_env.server:uptime() + local t = os.time()-prosody.start_time; + local seconds = t%60; + t = (t - seconds)/60; + local minutes = t%60; + t = (t - minutes)/60; + local hours = t%24; + t = (t - hours)/24; + local days = t; + return true, string.format("This server has been running for %d day%s, %d hour%s and %d minute%s (since %s)", + days, (days ~= 1 and "s") or "", hours, (hours ~= 1 and "s") or "", + minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time)); +end + +function def_env.server:shutdown(reason) + prosody.shutdown(reason); + return true, "Shutdown initiated"; +end + +def_env.module = {}; + +local function get_hosts_set(hosts, module) + if type(hosts) == "table" then + if hosts[1] then + return set.new(hosts); + elseif hosts._items then + return hosts; + end + elseif type(hosts) == "string" then + return set.new { hosts }; + elseif hosts == nil then + local mm = require "modulemanager"; + local hosts_set = set.new(array.collect(keys(prosody.hosts))) + / function (host) return (prosody.hosts[host].type == "local" or module and mm.is_loaded(host, module)) and host or nil; end; + if module and mm.get_module("*", module) then + hosts_set:add("*"); + end + return hosts_set; + end +end + +function def_env.module:load(name, hosts, config) + local mm = require "modulemanager"; + + hosts = get_hosts_set(hosts); + + -- Load the module for each host + local ok, err, count, mod = true, nil, 0, nil; + for host in hosts do + if (not mm.is_loaded(host, name)) then + mod, err = mm.load(host, name, config); + if not mod then + ok = false; + if err == "global-module-already-loaded" then + if count > 0 then + ok, err, count = true, nil, 1; + end + break; + end + self.session.print(err or "Unknown error loading module"); + else + count = count + 1; + self.session.print("Loaded for "..mod.module.host); + end + end + end + + return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); +end + +function def_env.module:unload(name, hosts) + local mm = require "modulemanager"; + + hosts = get_hosts_set(hosts, name); + + -- Unload the module for each host + local ok, err, count = true, nil, 0; + for host in hosts do + if mm.is_loaded(host, name) then + ok, err = mm.unload(host, name); + if not ok then + ok = false; + self.session.print(err or "Unknown error unloading module"); + else + count = count + 1; + self.session.print("Unloaded from "..host); + end + end + end + return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); +end + +function def_env.module:reload(name, hosts) + local mm = require "modulemanager"; + + hosts = array.collect(get_hosts_set(hosts, name)):sort(function (a, b) + if a == "*" then return true + elseif b == "*" then return false + else return a < b; end + end); + + -- Reload the module for each host + local ok, err, count = true, nil, 0; + for _, host in ipairs(hosts) do + if mm.is_loaded(host, name) then + ok, err = mm.reload(host, name); + if not ok then + ok = false; + self.session.print(err or "Unknown error reloading module"); + else + count = count + 1; + if ok == nil then + ok = true; + end + self.session.print("Reloaded on "..host); + end + end + end + return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); +end + +function def_env.module:list(hosts) + if hosts == nil then + hosts = array.collect(keys(prosody.hosts)); + table.insert(hosts, 1, "*"); + end + if type(hosts) == "string" then + hosts = { hosts }; + end + if type(hosts) ~= "table" then + return false, "Please supply a host or a list of hosts you would like to see"; + end + + local print = self.session.print; + for _, host in ipairs(hosts) do + print((host == "*" and "Global" or host)..":"); + local modules = array.collect(keys(modulemanager.get_modules(host) or {})):sort(); + if #modules == 0 then + if prosody.hosts[host] then + print(" No modules loaded"); + else + print(" Host not found"); + end + else + for _, name in ipairs(modules) do + print(" "..name); + end + end + end +end + +def_env.config = {}; +function def_env.config:load(filename, format) + local config_load = require "core.configmanager".load; + local ok, err = config_load(filename, format); + if not ok then + return false, err or "Unknown error loading config"; + end + return true, "Config loaded"; +end + +function def_env.config:get(host, section, key) + local config_get = require "core.configmanager".get + return true, tostring(config_get(host, section, key)); +end + +function def_env.config:reload() + local ok, err = prosody.reload_config(); + return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err); +end + +def_env.hosts = {}; +function def_env.hosts:list() + for host, host_session in pairs(hosts) do + self.session.print(host); + end + return true, "Done"; +end + +function def_env.hosts:add(name) +end + +def_env.c2s = {}; + +local function show_c2s(callback) + for hostname, host in pairs(hosts) do + for username, user in pairs(host.sessions or {}) do + for resource, session in pairs(user.sessions or {}) do + local jid = username.."@"..hostname.."/"..resource; + callback(jid, session); + end + end + end +end + +function def_env.c2s:count(match_jid) + local count = 0; + show_c2s(function (jid, session) + if (not match_jid) or jid:match(match_jid) then + count = count + 1; + end + end); + return true, "Total: "..count.." clients"; +end + +function def_env.c2s:show(match_jid) + local print, count = self.session.print, 0; + local curr_host; + show_c2s(function (jid, session) + if curr_host ~= session.host then + curr_host = session.host; + print(curr_host); + end + if (not match_jid) or jid:match(match_jid) then + count = count + 1; + local status, priority = "unavailable", tostring(session.priority or "-"); + if session.presence then + status = session.presence:child_with_name("show"); + if status then + status = status:get_text() or "[invalid!]"; + else + status = "available"; + end + end + print(" "..jid.." - "..status.."("..priority..")"); + end + end); + return true, "Total: "..count.." clients"; +end + +function def_env.c2s:show_insecure(match_jid) + local print, count = self.session.print, 0; + show_c2s(function (jid, session) + if ((not match_jid) or jid:match(match_jid)) and not session.secure then + count = count + 1; + print(jid); + end + end); + return true, "Total: "..count.." insecure client connections"; +end + +function def_env.c2s:show_secure(match_jid) + local print, count = self.session.print, 0; + show_c2s(function (jid, session) + if ((not match_jid) or jid:match(match_jid)) and session.secure then + count = count + 1; + print(jid); + end + end); + return true, "Total: "..count.." secure client connections"; +end + +function def_env.c2s:close(match_jid) + local count = 0; + show_c2s(function (jid, session) + if jid == match_jid or jid_bare(jid) == match_jid then + count = count + 1; + session:close(); + end + end); + return true, "Total: "..count.." sessions closed"; +end + +local function session_flags(session, line) + if session.cert_identity_status == "valid" then + line[#line+1] = "(secure)"; + elseif session.secure then + line[#line+1] = "(encrypted)"; + end + if session.compressed then + line[#line+1] = "(compressed)"; + end + if session.smacks then + line[#line+1] = "(sm)"; + end + if session.conn and session.conn:ip():match(":") then + line[#line+1] = "(IPv6)"; + end + return table.concat(line, " "); +end + +def_env.s2s = {}; +function def_env.s2s:show(match_jid) + local _print = self.session.print; + local print = self.session.print; + + local count_in, count_out = 0,0; + + for host, host_session in pairs(hosts) do + print = function (...) _print(host); _print(...); print = _print; end + for remotehost, session in pairs(host_session.s2sout) do + if (not match_jid) or remotehost:match(match_jid) or host:match(match_jid) then + count_out = count_out + 1; + print(session_flags(session, {" ", host, "->", remotehost})); + if session.sendq then + print(" There are "..#session.sendq.." queued outgoing stanzas for this connection"); + end + if session.type == "s2sout_unauthed" then + if session.connecting then + print(" Connection not yet established"); + if not session.srv_hosts then + if not session.conn then + print(" We do not yet have a DNS answer for this host's SRV records"); + else + print(" This host has no SRV records, using A record instead"); + end + elseif session.srv_choice then + print(" We are on SRV record "..session.srv_choice.." of "..#session.srv_hosts); + local srv_choice = session.srv_hosts[session.srv_choice]; + print(" Using "..(srv_choice.target or ".")..":"..(srv_choice.port or 5269)); + end + elseif session.notopen then + print(" The <stream> has not yet been opened"); + elseif not session.dialback_key then + print(" Dialback has not been initiated yet"); + elseif session.dialback_key then + print(" Dialback has been requested, but no result received"); + end + end + end + end + local subhost_filter = function (h) + return (match_jid and h:match(match_jid)); + end + for session in pairs(incoming_s2s) do + if session.to_host == host and ((not match_jid) or host:match(match_jid) + or (session.from_host and session.from_host:match(match_jid)) + -- Pft! is what I say to list comprehensions + or (session.hosts and #array.collect(keys(session.hosts)):filter(subhost_filter)>0)) then + count_in = count_in + 1; + print(session_flags(session, {" ", host, "<-", session.from_host or "(unknown)"})); + if session.type == "s2sin_unauthed" then + print(" Connection not yet authenticated"); + end + for name in pairs(session.hosts) do + if name ~= session.from_host then + print(" also hosts "..tostring(name)); + end + end + end + end + + print = _print; + end + + for session in pairs(incoming_s2s) do + if not session.to_host and ((not match_jid) or session.from_host and session.from_host:match(match_jid)) then + count_in = count_in + 1; + print("Other incoming s2s connections"); + print(" (unknown) <- "..(session.from_host or "(unknown)")); + end + end + + return true, "Total: "..count_out.." outgoing, "..count_in.." incoming connections"; +end + +local function print_subject(print, subject) + for _, entry in ipairs(subject) do + print( + (" %s: %q"):format( + entry.name or entry.oid, + entry.value:gsub("[\r\n%z%c]", " ") + ) + ); + end +end + +-- As much as it pains me to use the 0-based depths that OpenSSL does, +-- I think there's going to be more confusion among operators if we +-- break from that. +local function print_errors(print, errors) + for depth, t in ipairs(errors) do + print( + (" %d: %s"):format( + depth-1, + table.concat(t, "\n| ") + ) + ); + end +end + +function def_env.s2s:showcert(domain) + local ser = require "util.serialization".serialize; + local print = self.session.print; + local domain_sessions = set.new(array.collect(keys(incoming_s2s))) + /function(session) return session.from_host == domain and session or nil; end; + for local_host in values(prosody.hosts) do + local s2sout = local_host.s2sout; + if s2sout and s2sout[domain] then + domain_sessions:add(s2sout[domain]); + end + end + local cert_set = {}; + for session in domain_sessions do + local conn = session.conn; + conn = conn and conn:socket(); + if not conn.getpeerchain then + if conn.dohandshake then + error("This version of LuaSec does not support certificate viewing"); + end + else + local certs = conn:getpeerchain(); + local cert = certs[1]; + if cert then + local digest = cert:digest("sha1"); + if not cert_set[digest] then + local chain_valid, chain_errors = conn:getpeerverification(); + cert_set[digest] = { + { + from = session.from_host, + to = session.to_host, + direction = session.direction + }; + chain_valid = chain_valid; + chain_errors = chain_errors; + certs = certs; + }; + else + table.insert(cert_set[digest], { + from = session.from_host, + to = session.to_host, + direction = session.direction + }); + end + end + end + end + local domain_certs = array.collect(values(cert_set)); + -- Phew. We now have a array of unique certificates presented by domain. + local n_certs = #domain_certs; + + if n_certs == 0 then + return "No certificates found for "..domain; + end + + local function _capitalize_and_colon(byte) + return string.upper(byte)..":"; + end + local function pretty_fingerprint(hash) + return hash:gsub("..", _capitalize_and_colon):sub(1, -2); + end + + for cert_info in values(domain_certs) do + local certs = cert_info.certs; + local cert = certs[1]; + print("---") + print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1"))); + print(""); + local n_streams = #cert_info; + print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":"); + for _, stream in ipairs(cert_info) do + if stream.direction == "incoming" then + print(" "..stream.to.." <- "..stream.from); + else + print(" "..stream.from.." -> "..stream.to); + end + end + print(""); + local chain_valid, errors = cert_info.chain_valid, cert_info.chain_errors; + local valid_identity = cert_verify_identity(domain, "xmpp-server", cert); + if chain_valid then + print("Trusted certificate: Yes"); + else + print("Trusted certificate: No"); + print_errors(print, errors); + end + print(""); + print("Issuer: "); + print_subject(print, cert:issuer()); + print(""); + print("Valid for "..domain..": "..(valid_identity and "Yes" or "No")); + print("Subject:"); + print_subject(print, cert:subject()); + end + print("---"); + return ("Showing "..n_certs.." certificate" + ..(n_certs==1 and "" or "s") + .." presented by "..domain.."."); +end + +function def_env.s2s:close(from, to) + local print, count = self.session.print, 0; + + if not (from and to) then + return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'"; + elseif from == to then + return false, "Both from and to are the same... you can't do that :)"; + end + + if hosts[from] and not hosts[to] then + -- Is an outgoing connection + local session = hosts[from].s2sout[to]; + if not session then + print("No outgoing connection from "..from.." to "..to) + else + (session.close or s2smanager.destroy_session)(session); + count = count + 1; + print("Closed outgoing session from "..from.." to "..to); + end + elseif hosts[to] and not hosts[from] then + -- Is an incoming connection + for session in pairs(incoming_s2s) do + if session.to_host == to and session.from_host == from then + (session.close or s2smanager.destroy_session)(session); + count = count + 1; + end + end + + if count == 0 then + print("No incoming connections from "..from.." to "..to); + else + print("Closed "..count.." incoming session"..((count == 1 and "") or "s").." from "..from.." to "..to); + end + elseif hosts[to] and hosts[from] then + return false, "Both of the hostnames you specified are local, there are no s2s sessions to close"; + else + return false, "Neither of the hostnames you specified are being used on this server"; + end + + return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); +end + +function def_env.s2s:closeall(host) + local count = 0; + + if not host or type(host) ~= "string" then return false, "wrong syntax: please use s2s:closeall('hostname.tld')"; end + if hosts[host] then + for session in pairs(incoming_s2s) do + if session.to_host == host then + (session.close or s2smanager.destroy_session)(session); + count = count + 1; + end + end + for _, session in pairs(hosts[host].s2sout) do + (session.close or s2smanager.destroy_session)(session); + count = count + 1; + end + else + for session in pairs(incoming_s2s) do + if session.from_host == host then + (session.close or s2smanager.destroy_session)(session); + count = count + 1; + end + end + for _, h in pairs(hosts) do + if h.s2sout[host] then + (h.s2sout[host].close or s2smanager.destroy_session)(h.s2sout[host]); + count = count + 1; + end + end + end + + if count == 0 then return false, "No sessions to close."; + else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end +end + +def_env.host = {}; def_env.hosts = def_env.host; + +function def_env.host:activate(hostname, config) + return hostmanager.activate(hostname, config); +end +function def_env.host:deactivate(hostname, reason) + return hostmanager.deactivate(hostname, reason); +end + +function def_env.host:list() + local print = self.session.print; + local i = 0; + for host in values(array.collect(keys(prosody.hosts)):sort()) do + i = i + 1; + print(host); + end + return true, i.." hosts"; +end + +def_env.port = {}; + +function def_env.port:list() + local print = self.session.print; + local services = portmanager.get_active_services().data; + local ordered_services, n_ports = {}, 0; + for service, interfaces in pairs(services) do + table.insert(ordered_services, service); + end + table.sort(ordered_services); + for _, service in ipairs(ordered_services) do + local ports_list = {}; + for interface, ports in pairs(services[service]) do + for port in pairs(ports) do + table.insert(ports_list, "["..interface.."]:"..port); + end + end + n_ports = n_ports + #ports_list; + print(service..": "..table.concat(ports_list, ", ")); + end + return true, #ordered_services.." services listening on "..n_ports.." ports"; +end + +function def_env.port:close(close_port, close_interface) + close_port = assert(tonumber(close_port), "Invalid port number"); + local n_closed = 0; + local services = portmanager.get_active_services().data; + for service, interfaces in pairs(services) do + for interface, ports in pairs(interfaces) do + if not close_interface or close_interface == interface then + if ports[close_port] then + self.session.print("Closing ["..interface.."]:"..close_port.."..."); + local ok, err = portmanager.close(interface, close_port) + if not ok then + self.session.print("Failed to close "..interface.." "..close_port..": "..err); + else + n_closed = n_closed + 1; + end + end + end + end + end + return true, "Closed "..n_closed.." ports"; +end + +def_env.muc = {}; + +local console_room_mt = { + __index = function (self, k) return self.room[k]; end; + __tostring = function (self) + return "MUC room <"..self.room.jid..">"; + end; +}; + +local function check_muc(jid) + local room_name, host = jid_split(jid); + if not hosts[host] then + return nil, "No such host: "..host; + elseif not hosts[host].modules.muc then + return nil, "Host '"..host.."' is not a MUC service"; + end + return room_name, host; +end + +function def_env.muc:create(room_jid) + local room, host = check_muc(room_jid); + return hosts[host].modules.muc.create_room(room_jid); +end + +function def_env.muc:room(room_jid) + local room_name, host = check_muc(room_jid); + local room_obj = hosts[host].modules.muc.rooms[room_jid]; + if not room_obj then + return nil, "No such room: "..room_jid; + end + return setmetatable({ room = room_obj }, console_room_mt); +end + +local um = require"core.usermanager"; + +def_env.user = {}; +function def_env.user:create(jid, password) + local username, host = jid_split(jid); + if um.user_exists(username, host) then + return nil, "User exists"; + end + local ok, err = um.create_user(username, password, host); + if ok then + return true, "User created"; + else + return nil, "Could not create user: "..err; + end +end + +function def_env.user:delete(jid) + local username, host = jid_split(jid); + if not um.user_exists(username, host) then + return nil, "No such user"; + end + local ok, err = um.delete_user(username, host); + if ok then + return true, "User deleted"; + else + return nil, "Could not delete user: "..err; + end +end + +function def_env.user:password(jid, password) + local username, host = jid_split(jid); + if not um.user_exists(username, host) then + return nil, "No such user"; + end + local ok, err = um.set_password(username, password, host); + if ok then + return true, "User password changed"; + else + return nil, "Could not change password for user: "..err; + end +end + +function def_env.user:list(host, pat) + if not host then + return nil, "No host given"; + elseif not hosts[host] then + return nil, "No such host"; + end + local print = self.session.print; + local total, matches = 0, 0; + for user in um.users(host) do + if not pat or user:match(pat) then + print(user.."@"..host); + matches = matches + 1; + end + total = total + 1; + end + return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users"; +end + +def_env.xmpp = {}; + +local st = require "util.stanza"; +function def_env.xmpp:ping(localhost, remotehost) + if hosts[localhost] then + core_post_stanza(hosts[localhost], + st.iq{ from=localhost, to=remotehost, type="get", id="ping" } + :tag("ping", {xmlns="urn:xmpp:ping"})); + return true, "Sent ping"; + else + return nil, "No such host"; + end +end + +------------- + +function printbanner(session) + local option = module:get_option("console_banner"); + if option == nil or option == "full" or option == "graphic" then + session.print [[ + ____ \ / _ + | _ \ _ __ ___ ___ _-_ __| |_ _ + | |_) | '__/ _ \/ __|/ _ \ / _` | | | | + | __/| | | (_) \__ \ |_| | (_| | |_| | + |_| |_| \___/|___/\___/ \__,_|\__, | + A study in simplicity |___/ + +]] + end + if option == nil or option == "short" or option == "full" then + session.print("Welcome to the Prosody administration console. For a list of commands, type: help"); + session.print("You may find more help on using this console in our online documentation at "); + session.print("http://prosody.im/doc/console\n"); + end + if option and option ~= "short" and option ~= "full" and option ~= "graphic" then + if type(option) == "string" then + session.print(option) + elseif type(option) == "function" then + module:log("warn", "Using functions as value for the console_banner option is no longer supported"); + end + end +end + +module:provides("net", { + name = "console"; + listener = console_listener; + default_port = 5582; + private = true; +}); diff --git a/plugins/mod_announce.lua b/plugins/mod_announce.lua new file mode 100644 index 00000000..96976d6f --- /dev/null +++ b/plugins/mod_announce.lua @@ -0,0 +1,101 @@ +-- 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. +-- + +local st, jid = require "util.stanza", require "util.jid"; + +local hosts = prosody.hosts; +local is_admin = require "core.usermanager".is_admin; + +function send_to_online(message, host) + local sessions; + if host then + sessions = { [host] = hosts[host] }; + else + sessions = hosts; + end + + local c = 0; + for hostname, host_session in pairs(sessions) do + if host_session.sessions then + message.attr.from = hostname; + for username in pairs(host_session.sessions) do + c = c + 1; + message.attr.to = username.."@"..hostname; + module:send(message); + end + end + end + + return c; +end + + +-- Old <message>-based jabberd-style announcement sending +function handle_announcement(event) + local origin, stanza = event.origin, event.stanza; + local node, host, resource = jid.split(stanza.attr.to); + + if resource ~= "announce/online" then + return; -- Not an announcement + end + + if not is_admin(stanza.attr.from) then + -- Not an admin? Not allowed! + module:log("warn", "Non-admin '%s' tried to send server announcement", stanza.attr.from); + return; + end + + module:log("info", "Sending server announcement to all online users"); + local message = st.clone(stanza); + message.attr.type = "headline"; + message.attr.from = host; + + local c = send_to_online(message, host); + module:log("info", "Announcement sent to %d online users", c); + return true; +end +module:hook("message/host", handle_announcement); + +-- Ad-hoc command (XEP-0133) +local dataforms_new = require "util.dataforms".new; +local announce_layout = dataforms_new{ + title = "Making an Announcement"; + instructions = "Fill out this form to make an announcement to all\nactive users of this service."; + + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; + { name = "subject", type = "text-single", label = "Subject" }; + { name = "announcement", type = "text-multi", required = true, label = "Announcement" }; +}; + +function announce_handler(self, data, state) + if state then + if data.action == "cancel" then + return { status = "canceled" }; + end + + local fields = announce_layout:data(data.form); + + module:log("info", "Sending server announcement to all online users"); + local message = st.message({type = "headline"}, fields.announcement):up() + :tag("subject"):text(fields.subject or "Announcement"); + + local count = send_to_online(message, data.to); + + module:log("info", "Announcement sent to %d online users", count); + return { status = "completed", info = ("Announcement sent to %d online users"):format(count) }; + else + return { status = "executing", actions = {"next", "complete", default = "complete"}, form = announce_layout }, "executing"; + end + + return true; +end + +local adhoc_new = module:require "adhoc".new; +local announce_desc = adhoc_new("Send Announcement to Online Users", "http://jabber.org/protocol/admin#announce", announce_handler, "admin"); +module:provides("adhoc", announce_desc); + diff --git a/plugins/mod_auth_anonymous.lua b/plugins/mod_auth_anonymous.lua new file mode 100644 index 00000000..c877d532 --- /dev/null +++ b/plugins/mod_auth_anonymous.lua @@ -0,0 +1,71 @@ +-- 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. +-- + +local new_sasl = require "util.sasl".new; +local datamanager = require "util.datamanager"; +local hosts = prosody.hosts; + +-- define auth provider +local provider = {}; + +function provider.test_password(username, password) + return nil, "Password based auth not supported."; +end + +function provider.get_password(username) + return nil, "Password not available."; +end + +function provider.set_password(username, password) + return nil, "Password based auth not supported."; +end + +function provider.user_exists(username) + return nil, "Only anonymous users are supported."; -- FIXME check if anonymous user is connected? +end + +function provider.create_user(username, password) + return nil, "Account creation/modification not supported."; +end + +function provider.get_sasl_handler() + local anonymous_authentication_profile = { + anonymous = function(sasl, username, realm) + return true; -- for normal usage you should always return true here + end + }; + return new_sasl(module.host, anonymous_authentication_profile); +end + +function provider.users() + return next, hosts[host].sessions, nil; +end + +-- datamanager callback to disable writes +local function dm_callback(username, host, datastore, data) + if host == module.host then + return false; + end + return username, host, datastore, data; +end + +if not module:get_option_boolean("allow_anonymous_s2s", false) then + module:hook("route/remote", function (event) + return false; -- Block outgoing s2s from anonymous users + end, 300); +end + +function module.load() + datamanager.add_callback(dm_callback); +end +function module.unload() + datamanager.remove_callback(dm_callback); +end + +module:provides("auth", provider); + diff --git a/plugins/mod_auth_cyrus.lua b/plugins/mod_auth_cyrus.lua new file mode 100644 index 00000000..7668f8c4 --- /dev/null +++ b/plugins/mod_auth_cyrus.lua @@ -0,0 +1,84 @@ +-- 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. +-- + +local log = require "util.logger".init("auth_cyrus"); + +local usermanager_user_exists = require "core.usermanager".user_exists; + +local cyrus_service_realm = module:get_option("cyrus_service_realm"); +local cyrus_service_name = module:get_option("cyrus_service_name"); +local cyrus_application_name = module:get_option("cyrus_application_name"); +local require_provisioning = module:get_option("cyrus_require_provisioning") or false; +local host_fqdn = module:get_option("cyrus_server_fqdn"); + +prosody.unlock_globals(); --FIXME: Figure out why this is needed and + -- why cyrussasl isn't caught by the sandbox +local cyrus_new = require "util.sasl_cyrus".new; +prosody.lock_globals(); +local new_sasl = function(realm) + return cyrus_new( + cyrus_service_realm or realm, + cyrus_service_name or "xmpp", + cyrus_application_name or "prosody", + host_fqdn + ); +end + +do -- diagnostic + local list; + for mechanism in pairs(new_sasl(module.host):mechanisms()) do + list = (not(list) and mechanism) or (list..", "..mechanism); + end + if not list then + module:log("error", "No Cyrus SASL mechanisms available"); + else + module:log("debug", "Available Cyrus SASL mechanisms: %s", list); + end +end + +local host = module.host; + +-- define auth provider +local provider = {}; +log("debug", "initializing default authentication provider for host '%s'", host); + +function provider.test_password(username, password) + return nil, "Legacy auth not supported with Cyrus SASL."; +end + +function provider.get_password(username) + return nil, "Passwords unavailable for Cyrus SASL."; +end + +function provider.set_password(username, password) + return nil, "Passwords unavailable for Cyrus SASL."; +end + +function provider.user_exists(username) + if require_provisioning then + return usermanager_user_exists(username, host); + end + return true; +end + +function provider.create_user(username, password) + return nil, "Account creation/modification not available with Cyrus SASL."; +end + +function provider.get_sasl_handler() + local handler = new_sasl(host); + if require_provisioning then + function handler.require_provisioning(username) + return usermanager_user_exists(username, host); + end + end + return handler; +end + +module:provides("auth", provider); + diff --git a/plugins/mod_auth_internal_hashed.lua b/plugins/mod_auth_internal_hashed.lua new file mode 100644 index 00000000..2b041e43 --- /dev/null +++ b/plugins/mod_auth_internal_hashed.lua @@ -0,0 +1,148 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2010 Jeff Mitchell +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local log = require "util.logger".init("auth_internal_hashed"); +local getAuthenticationDatabaseSHA1 = require "util.sasl.scram".getAuthenticationDatabaseSHA1; +local usermanager = require "core.usermanager"; +local generate_uuid = require "util.uuid".generate; +local new_sasl = require "util.sasl".new; + +local accounts = module:open_store("accounts"); + +local to_hex; +do + local function replace_byte_with_hex(byte) + return ("%02x"):format(byte:byte()); + end + function to_hex(binary_string) + return binary_string:gsub(".", replace_byte_with_hex); + end +end + +local from_hex; +do + local function replace_hex_with_byte(hex) + return string.char(tonumber(hex, 16)); + end + function from_hex(hex_string) + return hex_string:gsub("..", replace_hex_with_byte); + end +end + + +-- Default; can be set per-user +local iteration_count = 4096; + +local host = module.host; +-- define auth provider +local provider = {}; +log("debug", "initializing internal_hashed authentication provider for host '%s'", host); + +function provider.test_password(username, password) + local credentials = accounts:get(username) or {}; + + if credentials.password ~= nil and string.len(credentials.password) ~= 0 then + if credentials.password ~= password then + return nil, "Auth failed. Provided password is incorrect."; + end + + if provider.set_password(username, credentials.password) == nil then + return nil, "Auth failed. Could not set hashed password from plaintext."; + else + return true; + end + end + + if credentials.iteration_count == nil or credentials.salt == nil or string.len(credentials.salt) == 0 then + 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 stored_key_hex = to_hex(stored_key); + local server_key_hex = to_hex(server_key); + + if valid and stored_key_hex == credentials.stored_key and server_key_hex == credentials.server_key then + return true; + else + return nil, "Auth failed. Invalid username, password, or password hash information."; + end +end + +function provider.set_password(username, password) + local account = accounts:get(username); + if account then + account.salt = account.salt or generate_uuid(); + account.iteration_count = account.iteration_count or iteration_count; + local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, account.salt, account.iteration_count); + local stored_key_hex = to_hex(stored_key); + local server_key_hex = to_hex(server_key); + + account.stored_key = stored_key_hex + account.server_key = server_key_hex + + account.password = nil; + return accounts:set(username, account); + end + return nil, "Account not available."; +end + +function provider.user_exists(username) + local account = accounts:get(username); + if not account then + log("debug", "account not found for username '%s' at host '%s'", username, host); + return nil, "Auth failed. Invalid username"; + end + return true; +end + +function provider.users() + return accounts:users(); +end + +function provider.create_user(username, password) + if password == nil then + return accounts:set(username, {}); + end + local salt = generate_uuid(); + local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, salt, iteration_count); + local stored_key_hex = to_hex(stored_key); + local server_key_hex = to_hex(server_key); + return accounts:set(username, {stored_key = stored_key_hex, server_key = server_key_hex, salt = salt, iteration_count = iteration_count}); +end + +function provider.delete_user(username) + return accounts:set(username, nil); +end + +function provider.get_sasl_handler() + local testpass_authentication_profile = { + plain_test = function(sasl, username, password, realm) + return usermanager.test_password(username, realm, password), true; + end, + scram_sha_1 = function(sasl, username, realm) + local credentials = accounts:get(username); + if not credentials then return; end + if credentials.password then + usermanager.set_password(username, credentials.password, host); + credentials = accounts:get(username); + if not credentials then return; end + end + + local stored_key, server_key, iteration_count, salt = credentials.stored_key, credentials.server_key, credentials.iteration_count, credentials.salt; + stored_key = stored_key and from_hex(stored_key); + server_key = server_key and from_hex(server_key); + return stored_key, server_key, iteration_count, salt, true; + end + }; + return new_sasl(host, testpass_authentication_profile); +end + +module:provides("auth", provider); + diff --git a/plugins/mod_auth_internal_plain.lua b/plugins/mod_auth_internal_plain.lua new file mode 100644 index 00000000..d226fdbe --- /dev/null +++ b/plugins/mod_auth_internal_plain.lua @@ -0,0 +1,81 @@ +-- 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. +-- + +local usermanager = require "core.usermanager"; +local new_sasl = require "util.sasl".new; + +local log = module._log; +local host = module.host; + +local accounts = module:open_store("accounts"); + +-- define auth provider +local provider = {}; +log("debug", "initializing internal_plain authentication provider for host '%s'", host); + +function provider.test_password(username, password) + log("debug", "test password for user %s at host %s", username, host); + local credentials = accounts:get(username) or {}; + + if password == credentials.password then + return true; + else + return nil, "Auth failed. Invalid username or password."; + end +end + +function provider.get_password(username) + log("debug", "get_password for username '%s' at host '%s'", username, host); + return (accounts:get(username) or {}).password; +end + +function provider.set_password(username, password) + local account = accounts:get(username); + if account then + account.password = password; + return accounts:set(username, account); + end + return nil, "Account not available."; +end + +function provider.user_exists(username) + local account = accounts:get(username); + if not account then + log("debug", "account not found for username '%s' at host '%s'", username, host); + return nil, "Auth failed. Invalid username"; + end + return true; +end + +function provider.users() + return accounts:users(); +end + +function provider.create_user(username, password) + return accounts:set(username, {password = password}); +end + +function provider.delete_user(username) + return accounts:set(username, nil); +end + +function provider.get_sasl_handler() + local getpass_authentication_profile = { + plain = function(sasl, username, realm) + local password = usermanager.get_password(username, realm); + if not password then + return "", nil; + end + return password, true; + end + }; + return new_sasl(host, getpass_authentication_profile); +end + +module:provides("auth", provider); + diff --git a/plugins/mod_bosh.lua b/plugins/mod_bosh.lua new file mode 100644 index 00000000..19f191c8 --- /dev/null +++ b/plugins/mod_bosh.lua @@ -0,0 +1,455 @@ +-- 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:set_global(); -- Global module + +local hosts = _G.hosts; +local new_xmpp_stream = require "util.xmppstream".new; +local sm = require "core.sessionmanager"; +local sm_destroy_session = sm.destroy_session; +local new_uuid = require "util.uuid".generate; +local fire_event = prosody.events.fire_event; +local core_process_stanza = prosody.core_process_stanza; +local st = require "util.stanza"; +local logger = require "util.logger"; +local log = logger.init("mod_bosh"); +local initialize_filters = require "util.filters".initialize; +local math_min = math.min; + +local xmlns_streams = "http://etherx.jabber.org/streams"; +local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; +local xmlns_bosh = "http://jabber.org/protocol/httpbind"; -- (hard-coded into a literal in session.send) + +local stream_callbacks = { + stream_ns = xmlns_bosh, stream_tag = "body", default_ns = "jabber:client" }; + +local BOSH_DEFAULT_HOLD = module:get_option_number("bosh_default_hold", 1); +local BOSH_DEFAULT_INACTIVITY = module:get_option_number("bosh_max_inactivity", 60); +local BOSH_DEFAULT_POLLING = module:get_option_number("bosh_max_polling", 5); +local BOSH_DEFAULT_REQUESTS = module:get_option_number("bosh_max_requests", 2); +local bosh_max_wait = module:get_option_number("bosh_max_wait", 120); + +local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure"); + +local default_headers = { ["Content-Type"] = "text/xml; charset=utf-8", ["Connection"] = "keep-alive" }; + +local cross_domain = module:get_option("cross_domain_bosh", false); +if cross_domain then + default_headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; + default_headers["Access-Control-Allow-Headers"] = "Content-Type"; + default_headers["Access-Control-Max-Age"] = "7200"; + + if cross_domain == true then + default_headers["Access-Control-Allow-Origin"] = "*"; + elseif type(cross_domain) == "table" then + cross_domain = table.concat(cross_domain, ", "); + end + if type(cross_domain) == "string" then + default_headers["Access-Control-Allow-Origin"] = cross_domain; + end +end + +local trusted_proxies = module:get_option_set("trusted_proxies", {"127.0.0.1"})._items; + +local function get_ip_from_request(request) + local ip = request.conn:ip(); + local forwarded_for = request.headers.x_forwarded_for; + if forwarded_for then + forwarded_for = forwarded_for..", "..ip; + for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do + if not trusted_proxies[forwarded_ip] then + ip = forwarded_ip; + end + end + end + return ip; +end + +local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat; +local os_time = os.time; + +-- All sessions, and sessions that have no requests open +local sessions, inactive_sessions = module:shared("sessions", "inactive_sessions"); + +-- Used to respond to idle sessions (those with waiting requests) +local waiting_requests = {}; +function on_destroy_request(request) + log("debug", "Request destroyed: %s", tostring(request)); + waiting_requests[request] = nil; + local session = sessions[request.context.sid]; + if session then + local requests = session.requests; + for i, r in ipairs(requests) do + if r == request then + t_remove(requests, i); + break; + end + end + + -- If this session now has no requests open, mark it as inactive + local max_inactive = session.bosh_max_inactive; + if max_inactive and #requests == 0 then + inactive_sessions[session] = os_time() + max_inactive; + (session.log or log)("debug", "BOSH session marked as inactive (for %ds)", max_inactive); + end + end +end + +function handle_OPTIONS(request) + local headers = {}; + for k,v in pairs(default_headers) do headers[k] = v; end + headers["Content-Type"] = nil; + return { headers = headers, body = "" }; +end + +function handle_POST(event) + log("debug", "Handling new request %s: %s\n----------", tostring(event.request), tostring(event.request.body)); + + local request, response = event.request, event.response; + response.on_destroy = on_destroy_request; + local body = request.body; + + local context = { request = request, response = response, notopen = true }; + local stream = new_xmpp_stream(context, stream_callbacks); + response.context = context; + + -- 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 + -- much of the session logic happens, because it's where we first + -- get to see the 'sid' of this request. + stream:feed(body); + + -- Stanzas (if any) in the request have now been processed, and + -- we take care of the high-level BOSH logic here, including + -- giving a response or putting the request "on hold". + local session = sessions[context.sid]; + if session then + -- Session was marked as inactive, since we have + -- a request open now, unmark it + if inactive_sessions[session] and #session.requests > 0 then + inactive_sessions[session] = nil; + end + + local r = session.requests; + log("debug", "Session %s has %d out of %d requests open", context.sid, #r, session.bosh_hold); + log("debug", "and there are %d things in the send_buffer:", #session.send_buffer); + for i, thing in ipairs(session.send_buffer) do + log("debug", " %s", tostring(thing)); + end + if #r > session.bosh_hold then + -- We are holding too many requests, send what's in the buffer, + log("debug", "We are holding too many requests, so..."); + if #session.send_buffer > 0 then + log("debug", "...sending what is in the buffer") + session.send(t_concat(session.send_buffer)); + session.send_buffer = {}; + else + -- or an empty response + log("debug", "...sending an empty response"); + session.send(""); + end + elseif #session.send_buffer > 0 then + log("debug", "Session has data in the send buffer, will send now.."); + local resp = t_concat(session.send_buffer); + session.send_buffer = {}; + session.send(resp); + end + + if not response.finished then + -- We're keeping this request open, to respond later + log("debug", "Have nothing to say, so leaving request unanswered for now"); + if session.bosh_wait then + waiting_requests[response] = os_time() + session.bosh_wait; + end + end + + if session.bosh_terminate then + session.log("debug", "Closing session with %d requests open", #session.requests); + session:close(); + return nil; + else + return true; -- Inform http server we shall reply later + end + end +end + + +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"); + + local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", + ["xmlns:stream"] = xmlns_streams }); + + + if reason then + close_reply.attr.condition = "remote-stream-error"; + if type(reason) == "string" then -- assume stream error + close_reply:tag("stream:error") + :tag(reason, {xmlns = xmlns_xmpp_streams}); + elseif type(reason) == "table" then + if reason.condition then + close_reply:tag("stream:error") + :tag(reason.condition, stream_xmlns_attr):up(); + if reason.text then + close_reply:tag("text", stream_xmlns_attr):text(reason.text):up(); + end + if reason.extra then + close_reply:add_child(reason.extra); + end + elseif reason.name then -- a stanza + close_reply = reason; + end + end + log("info", "Disconnecting client, <stream:error> is: %s", tostring(close_reply)); + end + + local response_body = tostring(close_reply); + for _, held_request in ipairs(session.requests) do + held_request.headers = default_headers; + held_request:send(response_body); + end + sessions[session.sid] = nil; + inactive_sessions[session] = nil; + sm_destroy_session(session); +end + +-- Handle the <body> tag in the request payload. +function stream_callbacks.streamopened(context, attr) + local request, response = context.request, context.response; + local sid = attr.sid; + log("debug", "BOSH body open (sid: %s)", sid or "<none>"); + if not sid then + -- New session request + context.notopen = nil; -- Signals that we accept this opening tag + + -- TODO: Sanity checks here (rid, to, known host, etc.) + if not hosts[attr.to] then + -- Unknown host + log("debug", "BOSH client tried to connect to unknown host: %s", tostring(attr.to)); + local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", + ["xmlns:stream"] = xmlns_streams, condition = "host-unknown" }); + response:send(tostring(close_reply)); + return; + end + + -- New session + sid = new_uuid(); + local session = { + type = "c2s_unauthed", conn = {}, sid = sid, rid = tonumber(attr.rid)-1, host = attr.to, + bosh_version = attr.ver, bosh_wait = math_min(attr.wait, bosh_max_wait), streamid = sid, + bosh_hold = BOSH_DEFAULT_HOLD, bosh_max_inactive = BOSH_DEFAULT_INACTIVITY, + requests = { }, send_buffer = {}, reset_stream = bosh_reset_stream, + close = bosh_close_stream, dispatch_stanza = core_process_stanza, notopen = true, + log = logger.init("bosh"..sid), secure = consider_bosh_secure or request.secure, + ip = get_ip_from_request(request); + }; + sessions[sid] = session; + + local filter = initialize_filters(session); + + session.log("debug", "BOSH session created for request from %s", session.ip); + log("info", "New BOSH session, assigned it sid '%s'", sid); + + -- Send creation response + local creating_session = true; + + local r = session.requests; + function session.send(s) + -- We need to ensure that outgoing stanzas have the jabber:client xmlns + if s.attr and not s.attr.xmlns then + s = st.clone(s); + s.attr.xmlns = "jabber:client"; + end + s = filter("stanzas/out", s); + --log("debug", "Sending BOSH data: %s", tostring(s)); + t_insert(session.send_buffer, tostring(s)); + + local oldest_request = r[1]; + if oldest_request and not session.bosh_processing then + log("debug", "We have an open request, so sending on that"); + oldest_request.headers = default_headers; + local body_attr = { xmlns = "http://jabber.org/protocol/httpbind", + ["xmlns:stream"] = "http://etherx.jabber.org/streams"; + type = session.bosh_terminate and "terminate" or nil; + sid = sid; + }; + if creating_session then + body_attr.inactivity = tostring(BOSH_DEFAULT_INACTIVITY); + body_attr.polling = tostring(BOSH_DEFAULT_POLLING); + body_attr.requests = tostring(BOSH_DEFAULT_REQUESTS); + body_attr.wait = tostring(session.bosh_wait); + body_attr.hold = tostring(session.bosh_hold); + body_attr.authid = sid; + body_attr.secure = "true"; + body_attr.ver = '1.6'; from = session.host; + body_attr["xmlns:xmpp"] = "urn:xmpp:xbosh"; + body_attr["xmpp:version"] = "1.0"; + end + oldest_request:send(st.stanza("body", body_attr):top_tag()..t_concat(session.send_buffer).."</body>"); + session.send_buffer = {}; + end + return true; + end + request.sid = sid; + end + + local session = sessions[sid]; + if not session then + -- Unknown sid + log("info", "Client tried to use sid '%s' which we don't know about", sid); + response.headers = default_headers; + response:send(tostring(st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", condition = "item-not-found" }))); + context.notopen = nil; + return; + end + + if session.rid then + local rid = tonumber(attr.rid); + local diff = rid - session.rid; + if diff > 1 then + session.log("warn", "rid too large (means a request was lost). Last rid: %d New rid: %s", session.rid, attr.rid); + elseif diff <= 0 then + -- Repeated, ignore + session.log("debug", "rid repeated (on request %s), ignoring: %s (diff %d)", request.id, session.rid, diff); + context.notopen = nil; + context.ignore = true; + context.sid = sid; + t_insert(session.requests, response); + return; + end + session.rid = rid; + end + + if attr.type == "terminate" then + -- Client wants to end this session, which we'll do + -- after processing any stanzas in this request + session.bosh_terminate = true; + end + + context.notopen = nil; -- Signals that we accept this opening tag + t_insert(session.requests, response); + context.sid = sid; + session.bosh_processing = true; -- Used to suppress replies until processing of this request is done + + if session.notopen then + local features = st.stanza("stream:features"); + hosts[session.host].events.fire_event("stream-features", { origin = session, features = features }); + fire_event("stream-features", session, features); + table.insert(session.send_buffer, tostring(features)); + session.notopen = nil; + end +end + +function stream_callbacks.handlestanza(context, stanza) + if context.ignore then return; end + log("debug", "BOSH stanza received: %s\n", stanza:top_tag()); + local session = sessions[context.sid]; + if session then + if stanza.attr.xmlns == xmlns_bosh then + stanza.attr.xmlns = nil; + end + stanza = session.filter("stanzas/in", stanza); + core_process_stanza(session, stanza); + end +end + +function stream_callbacks.streamclosed(request) + local session = sessions[request.sid]; + if session then + session.bosh_processing = false; + if #session.send_buffer > 0 then + session.send(""); + end + end +end + +function stream_callbacks.error(context, error) + log("debug", "Error parsing BOSH request payload; %s", error); + if not context.sid then + local response = context.response; + response.headers = default_headers; + response.status_code = 400; + response:send(); + return; + end + + local session = sessions[context.sid]; + if error == "stream-error" then -- Remote stream error, we close normally + session:close(); + else + session:close({ condition = "bad-format", text = "Error processing stream" }); + end +end + +local dead_sessions = {}; +function on_timer() + -- log("debug", "Checking for requests soon to timeout..."); + -- Identify requests timing out within the next few seconds + local now = os_time() + 3; + for request, reply_before in pairs(waiting_requests) do + if reply_before <= now then + log("debug", "%s was soon to timeout (at %d, now %d), sending empty response", tostring(request), reply_before, now); + -- Send empty response to let the + -- client know we're still here + if request.conn then + sessions[request.context.sid].send(""); + end + end + end + + now = now - 3; + local n_dead_sessions = 0; + for session, close_after in pairs(inactive_sessions) do + if close_after < now then + (session.log or log)("debug", "BOSH client inactive too long, destroying session at %d", now); + sessions[session.sid] = nil; + inactive_sessions[session] = nil; + n_dead_sessions = n_dead_sessions + 1; + dead_sessions[n_dead_sessions] = session; + end + end + + for i=1,n_dead_sessions do + local session = dead_sessions[i]; + dead_sessions[i] = nil; + sm_destroy_session(session, "BOSH client silent for over "..session.bosh_max_inactive.." seconds"); + end + return 1; +end +module:add_timer(1, on_timer); + + +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="http://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p> + </body></html>]]; +}; + +function module.add_host(module) + module:depends("http"); + module:provides("http", { + default_path = "/http-bind"; + route = { + ["GET"] = GET_response; + ["GET /"] = GET_response; + ["OPTIONS"] = handle_OPTIONS; + ["OPTIONS /"] = handle_OPTIONS; + ["POST"] = handle_POST; + ["POST /"] = handle_POST; + }; + }); +end diff --git a/plugins/mod_c2s.lua b/plugins/mod_c2s.lua new file mode 100644 index 00000000..efef8763 --- /dev/null +++ b/plugins/mod_c2s.lua @@ -0,0 +1,297 @@ +-- 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:set_global(); + +local add_task = require "util.timer".add_task; +local new_xmpp_stream = require "util.xmppstream".new; +local nameprep = require "util.encodings".stringprep.nameprep; +local sessionmanager = require "core.sessionmanager"; +local st = require "util.stanza"; +local sm_new_session, sm_destroy_session = sessionmanager.new_session, sessionmanager.destroy_session; +local uuid_generate = require "util.uuid".generate; + +local xpcall, tostring, type = xpcall, tostring, type; +local traceback = debug.traceback; + +local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; + +local log = module._log; + +local c2s_timeout = module:get_option_number("c2s_timeout"); +local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5); +local opt_keepalives = module:get_option_boolean("tcp_keepalives", false); + +local sessions = module:shared("sessions"); +local core_process_stanza = prosody.core_process_stanza; +local hosts = prosody.hosts; + +local stream_callbacks = { default_ns = "jabber:client", handlestanza = core_process_stanza }; +local listener = {}; + +--- Stream events handlers +local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'}; +local default_stream_attr = { ["xmlns:stream"] = "http://etherx.jabber.org/streams", xmlns = stream_callbacks.default_ns, version = "1.0", id = "" }; + +function stream_callbacks.streamopened(session, attr) + local send = session.send; + session.host = nameprep(attr.to); + if not session.host then + session:close{ condition = "improper-addressing", + text = "A valid 'to' attribute is required on stream headers" }; + return; + end + session.version = tonumber(attr.version) or 0; + session.streamid = uuid_generate(); + (session.log or session)("debug", "Client sent opening <stream:stream> to %s", session.host); + + if not hosts[session.host] then + -- We don't serve this host... + session:close{ condition = "host-unknown", text = "This server does not serve "..tostring(session.host)}; + return; + end + + send("<?xml version='1.0'?>"..st.stanza("stream:stream", { + xmlns = 'jabber:client', ["xmlns:stream"] = 'http://etherx.jabber.org/streams'; + id = session.streamid, from = session.host, version = '1.0', ["xml:lang"] = 'en' }):top_tag()); + + (session.log or log)("debug", "Sent reply <stream:stream> to client"); + session.notopen = nil; + + -- If session.secure is *false* (not nil) then it means we /were/ encrypting + -- since we now have a new stream header, session is secured + if session.secure == false then + session.secure = true; + + -- Check if TLS compression is used + local sock = session.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 + + local features = st.stanza("stream:features"); + hosts[session.host].events.fire_event("stream-features", { origin = session, features = features }); + module:fire_event("stream-features", session, features); + + send(features); +end + +function stream_callbacks.streamclosed(session) + session.log("debug", "Received </stream:stream>"); + session:close(false); +end + +function stream_callbacks.error(session, error, data) + if error == "no-stream" then + session.log("debug", "Invalid opening stream header"); + session:close("invalid-namespace"); + elseif error == "parse-error" then + (session.log or log)("debug", "Client XML parse error: %s", tostring(data)); + session:close("not-well-formed"); + elseif error == "stream-error" then + local condition, text = "undefined-condition"; + for child in data:children() do + if child.attr.xmlns == xmlns_xmpp_streams then + if child.name ~= "text" then + condition = child.name; + else + text = child:get_text(); + end + if condition ~= "undefined-condition" and text then + break; + end + end + end + text = condition .. (text and (" ("..text..")") or ""); + session.log("info", "Session closed by remote with error: %s", text); + session:close(nil, text); + end +end + +local function handleerr(err) log("error", "Traceback[c2s]: %s", traceback(tostring(err), 2)); end +function stream_callbacks.handlestanza(session, stanza) + stanza = session.filter("stanzas/in", stanza); + if stanza then + return xpcall(function () return core_process_stanza(session, stanza) end, handleerr); + end +end + +--- Session methods +local function session_close(session, reason) + local log = session.log or log; + if session.conn then + if session.notopen then + session.send("<?xml version='1.0'?>"); + session.send(st.stanza("stream:stream", default_stream_attr):top_tag()); + end + if reason then -- nil == no err, initiated by us, false == initiated by client + local stream_error = st.stanza("stream:error"); + if type(reason) == "string" then -- assume stream error + stream_error:tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' }); + elseif type(reason) == "table" then + if reason.condition then + stream_error:tag(reason.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 + elseif reason.name then -- a stanza + stream_error = reason; + end + end + stream_error = tostring(stream_error); + log("debug", "Disconnecting client, <stream:error> is: %s", stream_error); + session.send(stream_error); + end + + session.send("</stream:stream>"); + function session.send() return false; end + + local reason = (reason and (reason.text or reason.condition)) or reason; + session.log("info", "c2s stream for %s closed: %s", session.full_jid or ("<"..session.ip..">"), reason or "session closed"); + + -- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote + local conn = session.conn; + if reason == nil and not session.notopen and session.type == "c2s" then + -- Grace time to process data from authenticated cleanly-closed stream + add_task(stream_close_timeout, function () + if not session.destroyed then + session.log("warn", "Failed to receive a stream close response, closing connection anyway..."); + sm_destroy_session(session, reason); + conn:close(); + end + end); + else + sm_destroy_session(session, reason); + conn:close(); + end + end +end + +module:hook_global("user-deleted", function(event) + local username, host = event.username, event.host; + local user = hosts[host].sessions[username]; + if user and user.sessions then + for jid, session in pairs(user.sessions) do + session:close{ condition = "not-authorized", text = "Account deleted" }; + end + end +end, 200); + +--- Port listener +function listener.onconnect(conn) + local session = sm_new_session(conn); + sessions[conn] = session; + + session.log("info", "Client connected"); + + -- Client is using legacy SSL (otherwise mod_tls sets this flag) + if conn:ssl() then + session.secure = true; + + -- Check if TLS compression is used + local sock = conn:socket(); + if sock.info then + session.compressed = sock:info"compression"; + elseif sock.compression then + session.compressed = sock:compression(); --COMPAT mw/luasec-hg + end + end + + if opt_keepalives then + conn:setoption("keepalive", opt_keepalives); + end + + session.close = session_close; + + local stream = new_xmpp_stream(session, stream_callbacks); + session.stream = stream; + session.notopen = true; + + function session.reset_stream() + session.notopen = true; + session.stream:reset(); + end + + local filter = session.filter; + function session.data(data) + data = filter("bytes/in", data); + if data then + local ok, err = stream:feed(data); + if ok then return; end + log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_")); + session:close("not-well-formed"); + end + end + + + if c2s_timeout then + add_task(c2s_timeout, function () + if session.type == "c2s_unauthed" then + session:close("connection-timeout"); + end + end); + end + + session.dispatch_stanza = stream_callbacks.handlestanza; +end + +function listener.onincoming(conn, data) + local session = sessions[conn]; + if session then + session.data(data); + end +end + +function listener.ondisconnect(conn, err) + local session = sessions[conn]; + if session then + (session.log or log)("info", "Client disconnected: %s", err or "connection closed"); + sm_destroy_session(session, err); + sessions[conn] = nil; + end +end + +function listener.associate_session(conn, session) + sessions[conn] = session; +end + +module:hook("server-stopping", function(event) + local reason = event.reason; + for _, session in pairs(sessions) do + session:close{ condition = "system-shutdown", text = reason }; + end +end, 1000); + + + +module:provides("net", { + name = "c2s"; + listener = listener; + default_port = 5222; + encryption = "starttls"; + multiplex = { + pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:client%1.*>"; + }; +}); + +module:provides("net", { + name = "legacy_ssl"; + listener = listener; + encryption = "ssl"; + multiplex = { + pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:client%1.*>"; + }; +}); + + diff --git a/plugins/mod_component.lua b/plugins/mod_component.lua new file mode 100644 index 00000000..871a20e4 --- /dev/null +++ b/plugins/mod_component.lua @@ -0,0 +1,318 @@ +-- 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:set_global(); + +local t_concat = table.concat; + +local logger = require "util.logger"; +local sha1 = require "util.hashes".sha1; +local st = require "util.stanza"; + +local jid_split = require "util.jid".split; +local new_xmpp_stream = require "util.xmppstream".new; +local uuid_gen = require "util.uuid".generate; + +local core_process_stanza = prosody.core_process_stanza; +local hosts = prosody.hosts; + +local log = module._log; + +local sessions = module:shared("sessions"); + +function module.add_host(module) + if module:get_host_type() ~= "component" then + error("Don't load mod_component manually, it should be for a component, please see http://prosody.im/doc/components", 0); + end + + local env = module.environment; + env.connected = false; + + local send; + + local function on_destroy(session, err) + env.connected = false; + send = nil; + session.on_destroy = nil; + end + + -- Handle authentication attempts by component + local function handle_component_auth(event) + local session, stanza = event.origin, event.stanza; + + if session.type ~= "component_unauthed" then return; end + + if (not session.host) or #stanza.tags > 0 then + (session.log or log)("warn", "Invalid component handshake for host: %s", session.host); + session:close("not-authorized"); + return true; + end + + local secret = module:get_option("component_secret"); + if not secret then + (session.log or log)("warn", "Component attempted to identify as %s, but component_secret is not set", session.host); + session:close("not-authorized"); + return true; + end + + local supplied_token = t_concat(stanza); + local calculated_token = sha1(session.streamid..secret, true); + if supplied_token:lower() ~= calculated_token:lower() then + module:log("info", "Component authentication failed for %s", session.host); + session:close{ condition = "not-authorized", text = "Given token does not match calculated token" }; + return true; + end + + if env.connected then + module:log("error", "Second component attempted to connect, denying connection"); + session:close{ condition = "conflict", text = "Component already connected" }; + return true; + end + + env.connected = true; + send = session.send; + session.on_destroy = on_destroy; + session.component_validate_from = module:get_option_boolean("validate_from_addresses", true); + session.type = "component"; + module:log("info", "External component successfully authenticated"); + session.send(st.stanza("handshake")); + + return true; + end + module:hook("stanza/jabber:component:accept:handshake", handle_component_auth); + + -- Handle stanzas addressed to this component + local function handle_stanza(event) + local stanza = event.stanza; + if send then + stanza.attr.xmlns = nil; + send(stanza); + else + if stanza.name == "iq" and stanza.attr.type == "get" and stanza.attr.to == module.host then + local query = stanza.tags[1]; + local node = query.attr.node; + if query.name == "query" and query.attr.xmlns == "http://jabber.org/protocol/disco#info" and (not node or node == "") then + local name = module:get_option_string("name"); + if name then + event.origin.send(st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#info" }) + :tag("identity", { category = "component", type = "generic", name = module:get_option_string("name", "Prosody") })) + return true; + end + end + 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")); + end + end + return true; + end + + module:hook("iq/bare", handle_stanza, -1); + module:hook("message/bare", handle_stanza, -1); + module:hook("presence/bare", handle_stanza, -1); + module:hook("iq/full", handle_stanza, -1); + module:hook("message/full", handle_stanza, -1); + module:hook("presence/full", handle_stanza, -1); + module:hook("iq/host", handle_stanza, -1); + module:hook("message/host", handle_stanza, -1); + module:hook("presence/host", handle_stanza, -1); +end + +--- Network and stream part --- + +local xmlns_component = 'jabber:component:accept'; + +local listener = {}; + +--- Callbacks/data for xmppstream to handle streams for us --- + +local stream_callbacks = { default_ns = xmlns_component }; + +local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; + +function stream_callbacks.error(session, error, data, data2) + if session.destroyed then return; end + module:log("warn", "Error processing component stream: %s", tostring(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:close("not-well-formed"); + elseif error == "stream-error" then + local condition, text = "undefined-condition"; + for child in data:children() do + if child.attr.xmlns == xmlns_xmpp_streams then + if child.name ~= "text" then + condition = child.name; + else + text = child:get_text(); + end + if condition ~= "undefined-condition" and text then + break; + end + end + end + text = condition .. (text and (" ("..text..")") or ""); + session.log("info", "Session closed by remote with error: %s", text); + session:close(nil, text); + end +end + +function stream_callbacks.streamopened(session, attr) + if not hosts[attr.to] or not hosts[attr.to].modules.component then + session:close{ condition = "host-unknown", text = tostring(attr.to).." does not match any configured external components" }; + return; + end + session.host = attr.to; + session.streamid = uuid_gen(); + session.notopen = nil; + -- Return stream header + session.send("<?xml version='1.0'?>"); + session.send(st.stanza("stream:stream", { xmlns=xmlns_component, + ["xmlns:stream"]='http://etherx.jabber.org/streams', id=session.streamid, from=session.host }):top_tag()); +end + +function stream_callbacks.streamclosed(session) + session.log("debug", "Received </stream:stream>"); + session:close(); +end + +function stream_callbacks.handlestanza(session, stanza) + -- Namespaces are icky. + if not stanza.attr.xmlns and stanza.name == "handshake" then + stanza.attr.xmlns = xmlns_component; + end + if not stanza.attr.xmlns or stanza.attr.xmlns == "jabber:client" then + local from = stanza.attr.from; + if from then + if session.component_validate_from then + local _, domain = jid_split(stanza.attr.from); + if domain ~= session.host then + -- Return error + session.log("warn", "Component sent stanza with missing or invalid 'from' address"); + session:close{ + condition = "invalid-from"; + text = "Component tried to send from address <"..tostring(from) + .."> which is not in domain <"..tostring(session.host)..">"; + }; + return; + end + end + else + stanza.attr.from = session.host; -- COMPAT: Strictly we shouldn't allow this + end + if not stanza.attr.to then + session.log("warn", "Rejecting stanza with no 'to' address"); + session.send(st.error_reply(stanza, "modify", "bad-request", "Components MUST specify a 'to' address on stanzas")); + return; + end + end + return core_process_stanza(session, stanza); +end + +--- Closing a component connection +local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'}; +local default_stream_attr = { ["xmlns:stream"] = "http://etherx.jabber.org/streams", xmlns = stream_callbacks.default_ns, version = "1.0", id = "" }; +local function session_close(session, reason) + if session.destroyed then return; end + if session.conn then + if session.notopen then + session.send("<?xml version='1.0'?>"); + session.send(st.stanza("stream:stream", default_stream_attr):top_tag()); + end + if reason then + if type(reason) == "string" then -- assume stream error + module:log("info", "Disconnecting component, <stream:error> is: %s", reason); + session.send(st.stanza("stream:error"):tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' })); + elseif 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 + module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(stanza)); + session.send(stanza); + elseif reason.name then -- a stanza + module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(reason)); + session.send(reason); + end + end + end + session.send("</stream:stream>"); + session.conn:close(); + listener.ondisconnect(session.conn, "stream error"); + end +end + +--- Component connlistener + +function listener.onconnect(conn) + local _send = conn.write; + local session = { type = "component_unauthed", conn = conn, send = function (data) return _send(conn, tostring(data)); end }; + + -- Logging functions -- + local conn_name = "jcp"..tostring(session):match("[a-f0-9]+$"); + session.log = logger.init(conn_name); + session.close = session_close; + + session.log("info", "Incoming Jabber component connection"); + + local stream = new_xmpp_stream(session, stream_callbacks); + session.stream = stream; + + session.notopen = true; + + function session.reset_stream() + session.notopen = true; + session.stream:reset(); + end + + function session.data(conn, 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]", "_")); + session:close("not-well-formed"); + end + + session.dispatch_stanza = stream_callbacks.handlestanza; + + sessions[conn] = session; +end +function listener.onincoming(conn, data) + local session = sessions[conn]; + session.data(conn, data); +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)); + if session.on_destroy then session:on_destroy(err); end + sessions[conn] = nil; + for k in pairs(session) do + if k ~= "log" and k ~= "close" then + session[k] = nil; + end + end + session.destroyed = true; + session = nil; + end +end + +module:provides("net", { + name = "component"; + private = true; + listener = listener; + default_port = 5347; + multiplex = { + pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:component:accept%1.*>"; + }; +}); diff --git a/plugins/mod_compression.lua b/plugins/mod_compression.lua new file mode 100644 index 00000000..92856099 --- /dev/null +++ b/plugins/mod_compression.lua @@ -0,0 +1,195 @@ +-- Prosody IM +-- Copyright (C) 2009-2012 Tobias Markmann +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local zlib = require "zlib"; +local pcall = pcall; +local tostring = tostring; + +local xmlns_compression_feature = "http://jabber.org/features/compress" +local xmlns_compression_protocol = "http://jabber.org/protocol/compress" +local xmlns_stream = "http://etherx.jabber.org/streams"; +local compression_stream_feature = st.stanza("compression", {xmlns=xmlns_compression_feature}):tag("method"):text("zlib"):up(); +local add_filter = require "util.filters".add_filter; + +local compression_level = module:get_option_number("compression_level", 7); + +if not compression_level or compression_level < 1 or compression_level > 9 then + module:log("warn", "Invalid compression level in config: %s", tostring(compression_level)); + module:log("warn", "Module loading aborted. Compression won't be available."); + return; +end + +module:hook("stream-features", function(event) + local origin, features = event.origin, event.features; + if not origin.compressed and (origin.type == "c2s" or origin.type == "s2sin" or origin.type == "s2sout") then + -- FIXME only advertise compression support when TLS layer has no compression enabled + features:add_child(compression_stream_feature); + end +end); + +module:hook("s2s-stream-features", function(event) + local origin, features = event.origin, event.features; + -- FIXME only advertise compression support when TLS layer has no compression enabled + if not origin.compressed and (origin.type == "c2s" or origin.type == "s2sin" or origin.type == "s2sout") then + features:add_child(compression_stream_feature); + end +end); + +-- Hook to activate compression if remote server supports it. +module:hook_stanza(xmlns_stream, "features", + function (session, stanza) + if not session.compressed and (session.type == "c2s" or session.type == "s2sin" or session.type == "s2sout") then + -- does remote server support compression? + local comp_st = stanza:child_with_name("compression"); + if comp_st then + -- do we support the mechanism + for a in comp_st:children() do + local algorithm = a[1] + if algorithm == "zlib" then + session.sends2s(st.stanza("compress", {xmlns=xmlns_compression_protocol}):tag("method"):text("zlib")) + session.log("debug", "Enabled compression using zlib.") + return true; + end + end + session.log("debug", "Remote server supports no compression algorithm we support.") + end + end + end +, 250); + + +-- returns either nil or a fully functional ready to use inflate stream +local function get_deflate_stream(session) + local status, deflate_stream = pcall(zlib.deflate, compression_level); + if status == false then + local error_st = st.stanza("failure", {xmlns=xmlns_compression_protocol}):tag("setup-failed"); + (session.sends2s or session.send)(error_st); + session.log("error", "Failed to create zlib.deflate filter."); + module:log("error", "%s", tostring(deflate_stream)); + return + end + return deflate_stream +end + +-- returns either nil or a fully functional ready to use inflate stream +local function get_inflate_stream(session) + local status, inflate_stream = pcall(zlib.inflate); + if status == false then + local error_st = st.stanza("failure", {xmlns=xmlns_compression_protocol}):tag("setup-failed"); + (session.sends2s or session.send)(error_st); + session.log("error", "Failed to create zlib.inflate filter."); + module:log("error", "%s", tostring(inflate_stream)); + return + end + return inflate_stream +end + +-- setup compression for a stream +local function setup_compression(session, deflate_stream) + add_filter(session, "bytes/out", function(t) + local status, compressed, eof = pcall(deflate_stream, tostring(t), 'sync'); + if status == false then + module:log("warn", "%s", tostring(compressed)); + session:close({ + condition = "undefined-condition"; + text = compressed; + extra = st.stanza("failure", {xmlns="http://jabber.org/protocol/compress"}):tag("processing-failed"); + }); + return; + end + return compressed; + end); +end + +-- setup decompression for a stream +local function setup_decompression(session, inflate_stream) + add_filter(session, "bytes/in", function(data) + local status, decompressed, eof = pcall(inflate_stream, data); + if status == false then + module:log("warn", "%s", tostring(decompressed)); + session:close({ + condition = "undefined-condition"; + text = decompressed; + extra = st.stanza("failure", {xmlns="http://jabber.org/protocol/compress"}):tag("processing-failed"); + }); + return; + end + return decompressed; + end); +end + +module:hook("stanza/http://jabber.org/protocol/compress:compressed", function(event) + local session = event.origin; + + if session.type == "s2sout_unauthed" or session.type == "s2sout" then + session.log("debug", "Activating compression...") + -- create deflate and inflate streams + local deflate_stream = get_deflate_stream(session); + if not deflate_stream then return true; end + + local inflate_stream = get_inflate_stream(session); + if not inflate_stream then return true; end + + -- setup compression for session.w + setup_compression(session, deflate_stream); + + -- setup decompression for session.data + setup_decompression(session, inflate_stream); + session:reset_stream(); + session:open_stream(session.from_host, session.to_host); + session.compressed = true; + return true; + end +end); + +module:hook("stanza/http://jabber.org/protocol/compress:compress", function(event) + local session, stanza = event.origin, event.stanza; + + if session.type == "c2s" or session.type == "s2sin" or session.type == "c2s_unauthed" or session.type == "s2sin_unauthed" then + -- fail if we are already compressed + if session.compressed then + local error_st = st.stanza("failure", {xmlns=xmlns_compression_protocol}):tag("setup-failed"); + (session.sends2s or session.send)(error_st); + session.log("debug", "Client tried to establish another compression layer."); + return true; + end + + -- checking if the compression method is supported + local method = stanza:child_with_name("method"); + method = method and (method[1] or ""); + if method == "zlib" then + session.log("debug", "zlib compression enabled."); + + -- create deflate and inflate streams + local deflate_stream = get_deflate_stream(session); + if not deflate_stream then return true; end + + local inflate_stream = get_inflate_stream(session); + if not inflate_stream then return true; end + + (session.sends2s or session.send)(st.stanza("compressed", {xmlns=xmlns_compression_protocol})); + session:reset_stream(); + + -- setup compression for session.w + setup_compression(session, deflate_stream); + + -- setup decompression for session.data + setup_decompression(session, inflate_stream); + + session.compressed = true; + elseif method then + session.log("debug", "%s compression selected, but we don't support it.", tostring(method)); + local error_st = st.stanza("failure", {xmlns=xmlns_compression_protocol}):tag("unsupported-method"); + (session.sends2s or session.send)(error_st); + else + (session.sends2s or session.send)(st.stanza("failure", {xmlns=xmlns_compression_protocol}):tag("setup-failed")); + end + return true; + end +end); + diff --git a/plugins/mod_dialback.lua b/plugins/mod_dialback.lua new file mode 100644 index 00000000..9dcb0ed5 --- /dev/null +++ b/plugins/mod_dialback.lua @@ -0,0 +1,181 @@ +-- 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. +-- + +local hosts = _G.hosts; + +local log = module._log; + +local st = require "util.stanza"; +local sha256_hash = require "util.hashes".sha256; +local nameprep = require "util.encodings".stringprep.nameprep; + +local xmlns_stream = "http://etherx.jabber.org/streams"; + +local dialback_requests = setmetatable({}, { __mode = 'v' }); + +function generate_dialback(id, to, from) + return sha256_hash(id..to..from..hosts[from].dialback_secret, true); +end + +function initiate_dialback(session) + -- generate dialback key + session.dialback_key = generate_dialback(session.streamid, session.to_host, session.from_host); + session.sends2s(st.stanza("db:result", { from = session.from_host, to = session.to_host }):text(session.dialback_key)); + session.log("info", "sent dialback key on outgoing s2s stream"); +end + +function verify_dialback(id, to, from, key) + return key == generate_dialback(id, to, from); +end + +module:hook("stanza/jabber:server:dialback:verify", function(event) + local origin, stanza = event.origin, event.stanza; + + if origin.type == "s2sin_unauthed" or origin.type == "s2sin" then + -- We are being asked to verify the key, to ensure it was generated by us + origin.log("debug", "verifying that dialback key is ours..."); + local attr = stanza.attr; + if attr.type then + module:log("warn", "Ignoring incoming session from %s claiming a dialback key for %s is %s", + origin.from_host or "(unknown)", attr.from or "(unknown)", attr.type); + return true; + end + -- COMPAT: Grr, ejabberd breaks this one too?? it is black and white in XEP-220 example 34 + --if attr.from ~= origin.to_host then error("invalid-from"); end + local type; + if verify_dialback(attr.id, attr.from, attr.to, stanza[1]) then + type = "valid" + else + type = "invalid" + origin.log("warn", "Asked to verify a dialback key that was incorrect. An imposter is claiming to be %s?", attr.to); + end + origin.log("debug", "verified dialback key... it is %s", type); + origin.sends2s(st.stanza("db:verify", { from = attr.to, to = attr.from, id = attr.id, type = type }):text(stanza[1])); + return true; + end +end); + +module:hook("stanza/jabber:server:dialback:result", function(event) + local origin, stanza = event.origin, event.stanza; + + if origin.type == "s2sin_unauthed" or origin.type == "s2sin" then + -- he wants to be identified through dialback + -- We need to check the key with the Authoritative server + local attr = stanza.attr; + local to, from = nameprep(attr.to), nameprep(attr.from); + + if not hosts[to] then + -- Not a host that we serve + origin.log("info", "%s tried to connect to %s, which we don't serve", from, to); + origin:close("host-unknown"); + return true; + elseif not from then + origin:close("improper-addressing"); + end + + origin.hosts[from] = { dialback_key = stanza[1] }; + + dialback_requests[from.."/"..origin.streamid] = origin; + + -- COMPAT: ejabberd, gmail and perhaps others do not always set 'to' and 'from' + -- on streams. We fill in the session's to/from here instead. + if not origin.from_host then + origin.from_host = from; + end + if not origin.to_host then + origin.to_host = to; + end + + origin.log("debug", "asking %s if key %s belongs to them", from, stanza[1]); + module:fire_event("route/remote", { + from_host = to, to_host = from; + stanza = st.stanza("db:verify", { from = to, to = from, id = origin.streamid }):text(stanza[1]); + }); + return true; + end +end); + +module:hook("stanza/jabber:server:dialback:verify", function(event) + local origin, stanza = event.origin, event.stanza; + + if origin.type == "s2sout_unauthed" or origin.type == "s2sout" then + local attr = stanza.attr; + local dialback_verifying = dialback_requests[attr.from.."/"..(attr.id or "")]; + if dialback_verifying and attr.from == origin.to_host then + local valid; + if attr.type == "valid" then + module:fire_event("s2s-authenticated", { session = dialback_verifying, host = attr.from }); + valid = "valid"; + else + -- Warn the original connection that is was not verified successfully + log("warn", "authoritative server for %s denied the key", attr.from or "(unknown)"); + valid = "invalid"; + end + if dialback_verifying.destroyed then + log("warn", "Incoming s2s session %s was closed in the meantime, so we can't notify it of the db result", tostring(dialback_verifying):match("%w+$")); + else + dialback_verifying.sends2s( + st.stanza("db:result", { from = attr.to, to = attr.from, id = attr.id, type = valid }) + :text(dialback_verifying.hosts[attr.from].dialback_key)); + end + dialback_requests[attr.from.."/"..(attr.id or "")] = nil; + end + return true; + end +end); + +module:hook("stanza/jabber:server:dialback:result", function(event) + local origin, stanza = event.origin, event.stanza; + + if origin.type == "s2sout_unauthed" or origin.type == "s2sout" then + -- Remote server is telling us whether we passed dialback + + local attr = stanza.attr; + if not hosts[attr.to] then + origin:close("host-unknown"); + return true; + elseif hosts[attr.to].s2sout[attr.from] ~= origin then + -- This isn't right + origin:close("invalid-id"); + return true; + end + if stanza.attr.type == "valid" then + module:fire_event("s2s-authenticated", { session = origin, host = attr.from }); + else + origin:close("not-authorized", "dialback authentication failed"); + end + return true; + end +end); + +module:hook_stanza("urn:ietf:params:xml:ns:xmpp-sasl", "failure", function (origin, stanza) + if origin.external_auth == "failed" then + module:log("debug", "SASL EXTERNAL failed, falling back to dialback"); + initiate_dialback(origin); + return true; + end +end, 100); + +module:hook_stanza(xmlns_stream, "features", function (origin, stanza) + if not origin.external_auth or origin.external_auth == "failed" then + module:log("debug", "Initiating dialback..."); + initiate_dialback(origin); + return true; + end +end, 100); + +module:hook("s2sout-authenticate-legacy", function (event) + module:log("debug", "Initiating dialback..."); + initiate_dialback(event.origin); + return true; +end, 100); + +-- Offer dialback to incoming hosts +module:hook("s2s-stream-features", function (data) + data.features:tag("dialback", { xmlns='urn:xmpp:features:dialback' }):up(); +end); diff --git a/plugins/mod_disco.lua b/plugins/mod_disco.lua new file mode 100644 index 00000000..72c9a34c --- /dev/null +++ b/plugins/mod_disco.lua @@ -0,0 +1,159 @@ +-- 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. +-- + +local get_children = require "core.hostmanager".get_children; +local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local st = require "util.stanza" +local calculate_hash = require "util.caps".calculate_hash; + +local disco_items = module:get_option("disco_items") or {}; +do -- validate disco_items + for _, item in ipairs(disco_items) do + local err; + if type(item) ~= "table" then + err = "item is not a table"; + elseif type(item[1]) ~= "string" then + err = "item jid is not a string"; + elseif item[2] and type(item[2]) ~= "string" then + err = "item name is not a string"; + end + if err then + module:log("error", "option disco_items is malformed: %s", err); + disco_items = {}; -- TODO clean up data instead of removing it? + break; + end + end +end + +module:add_identity("server", "im", module:get_option_string("name", "Prosody")); -- FIXME should be in the non-existing mod_router +module:add_feature("http://jabber.org/protocol/disco#info"); +module:add_feature("http://jabber.org/protocol/disco#items"); + +-- Generate and cache disco result and caps hash +local _cached_server_disco_info, _cached_server_caps_feature, _cached_server_caps_hash; +local function build_server_disco_info() + local query = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info" }); + local done = {}; + for _,identity in ipairs(module:get_host_items("identity")) do + local identity_s = identity.category.."\0"..identity.type; + if not done[identity_s] then + query:tag("identity", identity):up(); + done[identity_s] = true; + end + end + for _,feature in ipairs(module:get_host_items("feature")) do + if not done[feature] then + query:tag("feature", {var=feature}):up(); + done[feature] = true; + end + end + for _,extension in ipairs(module:get_host_items("extension")) do + if not done[extension] then + query:add_child(extension); + done[extension] = true; + end + end + _cached_server_disco_info = query; + _cached_server_caps_hash = calculate_hash(query); + _cached_server_caps_feature = st.stanza("c", { + xmlns = "http://jabber.org/protocol/caps"; + hash = "sha-1"; + node = "http://prosody.im"; + ver = _cached_server_caps_hash; + }); +end +local function clear_disco_cache() + _cached_server_disco_info, _cached_server_caps_feature, _cached_server_caps_hash = nil, nil, nil; +end +local function get_server_disco_info() + if not _cached_server_disco_info then build_server_disco_info(); end + return _cached_server_disco_info; +end +local function get_server_caps_feature() + if not _cached_server_caps_feature then build_server_disco_info(); end + return _cached_server_caps_feature; +end +local function get_server_caps_hash() + if not _cached_server_caps_hash then build_server_disco_info(); end + return _cached_server_caps_hash; +end + +module:hook("item-added/identity", clear_disco_cache); +module:hook("item-added/feature", clear_disco_cache); +module:hook("item-added/extension", clear_disco_cache); +module:hook("item-removed/identity", clear_disco_cache); +module:hook("item-removed/feature", clear_disco_cache); +module:hook("item-removed/extension", clear_disco_cache); + +-- Handle disco requests to the server +module:hook("iq/host/http://jabber.org/protocol/disco#info:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type ~= "get" then return; end + local node = stanza.tags[1].attr.node; + if node and node ~= "" and node ~= "http://prosody.im#"..get_server_caps_hash() then return; end -- TODO fire event? + local reply_query = get_server_disco_info(); + reply_query.node = node; + local reply = st.reply(stanza):add_child(reply_query); + origin.send(reply); + return true; +end); +module:hook("iq/host/http://jabber.org/protocol/disco#items:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type ~= "get" then return; end + local node = stanza.tags[1].attr.node; + if node and node ~= "" then return; end -- TODO fire event? + + local reply = st.reply(stanza):query("http://jabber.org/protocol/disco#items"); + for jid, name in pairs(get_children(module.host)) do + reply:tag("item", {jid = jid, name = name~=true and name or nil}):up(); + end + for _, item in ipairs(disco_items) do + reply:tag("item", {jid=item[1], name=item[2]}):up(); + end + origin.send(reply); + return true; +end); + +-- Handle caps stream feature +module:hook("stream-features", function (event) + if event.origin.type == "c2s" then + event.features:add_child(get_server_caps_feature()); + end +end); + +-- Handle disco requests to user accounts +module:hook("iq/bare/http://jabber.org/protocol/disco#info:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type ~= "get" then return; end + local node = stanza.tags[1].attr.node; + if node and node ~= "" then return; end -- TODO fire event? + local username = jid_split(stanza.attr.to) or origin.username; + if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then + local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info'}); + if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account + module:fire_event("account-disco-info", { origin = origin, stanza = reply }); + origin.send(reply); + return true; + end +end); +module:hook("iq/bare/http://jabber.org/protocol/disco#items:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type ~= "get" then return; end + local node = stanza.tags[1].attr.node; + if node and node ~= "" then return; end -- TODO fire event? + local username = jid_split(stanza.attr.to) or origin.username; + if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then + local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#items'}); + if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account + module:fire_event("account-disco-items", { origin = origin, stanza = reply }); + origin.send(reply); + return true; + end +end); diff --git a/plugins/mod_groups.lua b/plugins/mod_groups.lua new file mode 100644 index 00000000..f7f632c2 --- /dev/null +++ b/plugins/mod_groups.lua @@ -0,0 +1,128 @@ +-- 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. +-- + + +local groups; +local members; + +local groups_file; + +local jid, datamanager = require "util.jid", require "util.datamanager"; +local jid_prep = jid.prep; + +local module_host = module:get_host(); + +function inject_roster_contacts(username, host, roster) + --module:log("debug", "Injecting group members to roster"); + local bare_jid = username.."@"..host; + if not members[bare_jid] and not members[false] then return; end -- Not a member of any groups + + 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)); + if jid ~= bare_jid then + if not roster[jid] then roster[jid] = {}; end + roster[jid].subscription = "both"; + if groups[group_name][jid] then + roster[jid].name = groups[group_name][jid]; + end + if not roster[jid].groups then + roster[jid].groups = { [group_name] = true }; + end + roster[jid].groups[group_name] = true; + roster[jid].persist = false; + end + end + end + + -- Find groups this JID is a member of + if members[bare_jid] then + for _, group_name in ipairs(members[bare_jid]) do + --module:log("debug", "Importing group %s", group_name); + import_jids_to_roster(group_name); + end + end + + -- Import public groups + if members[false] then + for _, group_name in ipairs(members[false]) do + --module:log("debug", "Importing group %s", group_name); + import_jids_to_roster(group_name); + end + end + + if roster[false] then + roster[false].version = true; + end +end + +function remove_virtual_contacts(username, host, datastore, data) + if host == module_host and datastore == "roster" then + local new_roster = {}; + for jid, contact in pairs(data) do + if contact.persist ~= false then + new_roster[jid] = contact; + end + end + if new_roster[false] then + new_roster[false].version = nil; -- Version is void + end + return username, host, datastore, new_roster; + end + + return username, host, datastore, data; +end + +function module.load() + groups_file = module:get_option_string("groups_file"); + if not groups_file then return; end + + module:hook("roster-load", inject_roster_contacts); + datamanager.add_callback(remove_virtual_contacts); + + groups = { default = {} }; + members = { }; + local curr_group = "default"; + for line in io.lines(groups_file) do + if line:match("^%s*%[.-%]%s*$") then + curr_group = line:match("^%s*%[(.-)%]%s*$"); + if curr_group:match("^%+") then + curr_group = curr_group:gsub("^%+", ""); + if not members[false] then + members[false] = {}; + end + members[false][#members[false]+1] = curr_group; -- Is a public group + end + module:log("debug", "New group: %s", tostring(curr_group)); + groups[curr_group] = groups[curr_group] or {}; + else + -- Add JID + local entryjid, name = line:match("([^=]*)=?(.*)"); + module:log("debug", "entryjid = '%s', name = '%s'", entryjid, name); + local jid; + jid = jid_prep(entryjid:match("%S+")); + if jid then + module:log("debug", "New member of %s: %s", tostring(curr_group), tostring(jid)); + groups[curr_group][jid] = name or false; + members[jid] = members[jid] or {}; + members[jid][#members[jid]+1] = curr_group; + end + end + end + module:log("info", "Groups loaded successfully"); +end + +function module.unload() + datamanager.remove_callback(remove_virtual_contacts); +end + +-- Public for other modules to access +function group_contains(group_name, jid) + return groups[group_name][jid]; +end diff --git a/plugins/mod_http.lua b/plugins/mod_http.lua new file mode 100644 index 00000000..0689634e --- /dev/null +++ b/plugins/mod_http.lua @@ -0,0 +1,146 @@ +-- Prosody IM +-- Copyright (C) 2008-2012 Matthew Wild +-- Copyright (C) 2008-2012 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +module:set_global(); +module:depends("http_errors"); + +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 server = require "net.http.server"; + +server.set_default_host(module:get_option_string("http_default_host")); + +local function normalize_path(path) + if path:sub(-1,-1) == "/" then path = path:sub(1, -2); end + if path:sub(1,1) ~= "/" then path = "/"..path; end + return path; +end + +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) + method, path = key, ""; + end + if method:sub(1,1) == "/" then + return nil; + end + if app_path == "/" and path:sub(1,1) == "/" then + app_path = ""; + end + return method:upper().." "..host..app_path..path; +end + +local function get_base_path(host_module, app_name, default_app_path) + return (normalize_path(host_module:get_option("http_paths", {})[app_name] -- Host + or module:get_option("http_paths", {})[app_name] -- Global + or default_app_path)) -- Default + :gsub("%$(%w+)", { host = module.host }); +end + +local ports_by_scheme = { http = 80, https = 443, }; + +-- Helper to deduce a module's external URL +function moduleapi.http_url(module, app_name, default_path) + app_name = app_name or (module.name:gsub("^http_", "")); + local external_url = url_parse(module:get_option_string("http_external_url")) or {}; + local services = portmanager.get_active_services(); + local http_services = services:get("https") or services:get("http") or {}; + for interface, ports in pairs(http_services) do + for port, services in pairs(ports) do + local url = { + scheme = (external_url.scheme or services[1].service.name); + host = (external_url.host or module:get_option_string("http_host", module.host)); + port = tonumber(external_url.port) or port or 80; + path = normalize_path(external_url.path or "/").. + (get_base_path(module, app_name, default_path or "/"..app_name):sub(2)); + } + if ports_by_scheme[url.scheme] == url.port then url.port = nil end + return url_build(url); + end + end +end + +function module.add_host(module) + local host = module:get_option_string("http_host", module.host); + local apps = {}; + module.environment.apps = apps; + local function http_app_added(event) + local app_name = event.item.name; + local default_app_path = event.item.default_path or "/"..app_name; + local app_path = get_base_path(module, app_name, default_app_path); + if not app_name then + -- TODO: Link to docs + module:log("error", "HTTP app has no 'name', add one or use module:provides('http', app)"); + return; + end + apps[app_name] = apps[app_name] or {}; + local app_handlers = apps[app_name]; + for key, handler in pairs(event.item.route or {}) do + local event_name = get_http_event(host, app_path, key); + if event_name then + if type(handler) ~= "function" then + local data = handler; + handler = function () return data; end + elseif event_name:sub(-2, -1) == "/*" then + local base_path_len = #event_name:match("/.+$"); + local _handler = handler; + handler = function (event) + local path = event.request.path:sub(base_path_len); + return _handler(event, path); + end; + end + if not app_handlers[event_name] then + app_handlers[event_name] = handler; + module:hook_object_event(server, event_name, handler); + else + module:log("warn", "App %s added handler twice for '%s', ignoring", app_name, event_name); + end + else + module:log("error", "Invalid route in %s, %q. See http://prosody.im/doc/developers/http#routes", app_name, key); + end + end + end + + local function http_app_removed(event) + local app_handlers = apps[event.item.name]; + apps[event.item.name] = nil; + for event, handler in pairs(app_handlers) do + module:unhook_object_event(server, event, handler); + end + end + + module:handle_items("http-provider", http_app_added, http_app_removed); + + server.add_host(host); + function module.unload() + server.remove_host(host); + end +end + +module:provides("net", { + name = "http"; + listener = server.listener; + default_port = 5280; + multiplex = { + pattern = "^[A-Z]"; + }; +}); + +module:provides("net", { + name = "https"; + listener = server.listener; + default_port = 5281; + encryption = "ssl"; + ssl_config = { verify = "none" }; + multiplex = { + pattern = "^[A-Z]"; + }; +}); diff --git a/plugins/mod_http_errors.lua b/plugins/mod_http_errors.lua new file mode 100644 index 00000000..2568ea80 --- /dev/null +++ b/plugins/mod_http_errors.lua @@ -0,0 +1,75 @@ +module:set_global(); + +local server = require "net.http.server"; +local codes = require "net.http.codes"; + +local show_private = module:get_option_boolean("http_errors_detailed", false); +local always_serve = module:get_option_boolean("http_errors_always_show", true); +local default_message = { module:get_option_string("http_errors_default_message", "That's all I know.") }; +local default_messages = { + [400] = { "What kind of request do you call that??" }; + [403] = { "You're not allowed to do that." }; + [404] = { "Whatever you were looking for is not here. %"; + "Where did you put it?", "It's behind you.", "Keep looking." }; + [500] = { "% Check your error log for more info."; + "Gremlins.", "It broke.", "Don't look at me." }; +}; + +local messages = setmetatable(module:get_option("http_errors_messages", {}), { __index = default_messages }); + +local html = [[ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <style> + body{ + margin-top:14%; + text-align:center; + background-color:#F8F8F8; + font-family:sans-serif; + } + h1{ + font-size:xx-large; + } + p{ + font-size:x-large; + } + p+p { font-size: large; font-family: courier } + </style> +</head> +<body> + <h1>$title</h1> + <p>$message</p> + <p>$extra</p> +</body> +</html>]]; +html = html:gsub("%s%s+", ""); + +local entities = { + ["<"] = "<", [">"] = ">", ["&"] = "&", + ["'"] = "'", ["\""] = """, ["\n"] = "<br/>", +}; + +local function tohtml(plain) + return (plain:gsub("[<>&'\"\n]", entities)); + +end + +local function get_page(code, extra) + local message = messages[code]; + if always_serve or message then + message = message or default_message; + return (html:gsub("$(%a+)", { + title = rawget(codes, code) or ("Code "..tostring(code)); + message = message[1]:gsub("%%", function () + return message[math.random(2, math.max(#message,2))]; + end); + extra = tohtml(extra or ""); + })); + end +end + +module:hook_object_event(server, "http-error", function (event) + return get_page(event.code, (show_private and event.private_message) or event.message); +end); diff --git a/plugins/mod_http_files.lua b/plugins/mod_http_files.lua new file mode 100644 index 00000000..915bec58 --- /dev/null +++ b/plugins/mod_http_files.lua @@ -0,0 +1,153 @@ +-- 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: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 base_path = module:get_option_string("http_files_dir", module:get_option_string("http_path")); +local dir_indices = module:get_option("http_index_files", { "index.html", "index.htm" }); +local directory_index = module:get_option_boolean("http_dir_listing"); + +local mime_map = module:shared("mime").types; +if not mime_map then + mime_map = { + html = "text/html", htm = "text/html", + xml = "application/xml", + txt = "text/plain", + css = "text/css", + js = "application/javascript", + png = "image/png", + gif = "image/gif", + jpeg = "image/jpeg", jpg = "image/jpeg", + svg = "image/svg+xml", + }; + module:shared("mime").types = mime_map; + + local mime_types, err = open(module:get_option_string("mime_types_file", "/etc/mime.types"),"r"); + if mime_types then + local mime_data = mime_types:read("*a"); + mime_types:close(); + setmetatable(mime_map, { + __index = function(t, ext) + local typ = mime_data:match("\n(%S+)[^\n]*%s"..(ext:lower()).."%s") or "application/octet-stream"; + t[ext] = typ; + return typ; + end + }); + end +end + +local cache = setmetatable({}, { __mode = "kv" }); -- Let the garbage collector have it if it wants to. + +function serve(opts) + if type(opts) ~= "table" then -- assume path string + opts = { path = opts }; + end + 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 orig_path = request.path; + local full_path = base_path .. (path and "/"..path or ""); + local attr = stat(full_path); + 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 = ("%02x-%x-%x-%x"):format(attr.dev or 0, attr.ino 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[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 path = { is_absolute = true, is_directory = true }; + for dir in orig_path:gmatch("[^/]+") do path[#path+1]=dir; end + response_headers.location = build_path(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[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 f then + data, err = f:read("*a"); + f:close(); + end + if not data then + module:log("debug", "Could not open or read %s. Error was %s", full_path, err); + return 403; + end + local ext = full_path:match("%.([^./]+)$"); + local content_type = ext and mime_map[ext]; + cache[orig_path] = { data = data; content_type = content_type; etag = etag }; + response_headers.content_type = content_type; + end + + return response:send(data); + end + + return serve_file; +end + +function wrap_route(routes) + for route,handler in pairs(routes) do + if type(handler) ~= "function" then + routes[route] = 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 + diff --git a/plugins/mod_iq.lua b/plugins/mod_iq.lua new file mode 100644 index 00000000..e7901ab4 --- /dev/null +++ b/plugins/mod_iq.lua @@ -0,0 +1,76 @@ +-- 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. +-- + + +local st = require "util.stanza"; + +local full_sessions = prosody.full_sessions; + +if module:get_host_type() == "local" then + module:hook("iq/full", function(data) + -- IQ to full JID recieved + local origin, stanza = data.origin, data.stanza; + + local session = full_sessions[stanza.attr.to]; + if not (session and session.send(stanza)) then + if stanza.attr.type == "get" or stanza.attr.type == "set" then + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + end + end + return true; + end); +end + +module:hook("iq/bare", function(data) + -- IQ to bare JID recieved + local stanza = data.stanza; + local type = stanza.attr.type; + + -- TODO fire post processing events + if type == "get" or type == "set" then + local child = stanza.tags[1]; + local xmlns = child.attr.xmlns or "jabber:client"; + local ret = module:fire_event("iq/bare/"..xmlns..":"..child.name, data); + if ret ~= nil then return ret; end + return module:fire_event("iq-"..type.."/bare/"..xmlns..":"..child.name, data); + else + return module:fire_event("iq-"..type.."/bare/"..stanza.attr.id, data); + end +end); + +module:hook("iq/self", function(data) + -- IQ to self JID recieved + local stanza = data.stanza; + local type = stanza.attr.type; + + if type == "get" or type == "set" then + local child = stanza.tags[1]; + local xmlns = child.attr.xmlns or "jabber:client"; + local ret = module:fire_event("iq/self/"..xmlns..":"..child.name, data); + if ret ~= nil then return ret; end + return module:fire_event("iq-"..type.."/self/"..xmlns..":"..child.name, data); + else + return module:fire_event("iq-"..type.."/self/"..stanza.attr.id, data); + end +end); + +module:hook("iq/host", function(data) + -- IQ to a local host recieved + local stanza = data.stanza; + local type = stanza.attr.type; + + if type == "get" or type == "set" then + local child = stanza.tags[1]; + local xmlns = child.attr.xmlns or "jabber:client"; + local ret = module:fire_event("iq/host/"..xmlns..":"..child.name, data); + if ret ~= nil then return ret; end + return module:fire_event("iq-"..type.."/host/"..xmlns..":"..child.name, data); + else + return module:fire_event("iq-"..type.."/host/"..stanza.attr.id, data); + end +end); diff --git a/plugins/mod_lastactivity.lua b/plugins/mod_lastactivity.lua new file mode 100644 index 00000000..11053709 --- /dev/null +++ b/plugins/mod_lastactivity.lua @@ -0,0 +1,52 @@ +-- 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. +-- + +local st = require "util.stanza"; +local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; + +module:add_feature("jabber:iq:last"); + +local map = {}; + +module:hook("pre-presence/bare", function(event) + local stanza = event.stanza; + if not(stanza.attr.to) and stanza.attr.type == "unavailable" then + local t = os.time(); + local s = stanza:child_with_name("status"); + s = s and #s.tags == 0 and s[1] or ""; + map[event.origin.username] = {s = s, t = t}; + end +end, 10); + +module:hook("iq/bare/jabber:iq:last:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "get" then + local username = jid_split(stanza.attr.to) or origin.username; + if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then + local seconds, text = "0", ""; + if map[username] then + seconds = tostring(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)); + else + origin.send(st.error_reply(stanza, 'auth', 'forbidden')); + end + return true; + end +end); + +module.save = function() + return {map = map}; +end +module.restore = function(data) + map = data.map or {}; +end + diff --git a/plugins/mod_legacyauth.lua b/plugins/mod_legacyauth.lua index 8ac10bd4..5fb66441 100644 --- a/plugins/mod_legacyauth.lua +++ b/plugins/mod_legacyauth.lua @@ -1,57 +1,86 @@ +-- 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. +-- + + local st = require "util.stanza"; -local send = require "core.sessionmanager".send_to_session; local t_concat = table.concat; -add_iq_handler("c2s_unauthed", "jabber:iq:auth", - function (session, stanza) - local username = stanza.tags[1]:child_with_name("username"); - local password = stanza.tags[1]:child_with_name("password"); - local resource = stanza.tags[1]:child_with_name("resource"); - if not (username and password and resource) then - local reply = st.reply(stanza); - send(session, reply:query("jabber:iq:auth") - :tag("username"):up() - :tag("password"):up() - :tag("resource"):up()); - return true; - else - username, password, resource = t_concat(username), t_concat(password), t_concat(resource); - local reply = st.reply(stanza); - require "core.usermanager" - if usermanager.validate_credentials(session.host, username, password) then - -- Authentication successful! - local success, err = sessionmanager.make_authenticated(session, username); - if success then - success, err = sessionmanager.bind_resource(session, resource); - --FIXME: Reply with error - if not success then - local reply = st.reply(stanza); - reply.attr.type = "error"; - if err == "conflict" then - reply:tag("error", { code = "409", type = "cancel" }) - :tag("conflict", { xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" }); - elseif err == "constraint" then - reply:tag("error", { code = "409", type = "cancel" }) - :tag("already-bound", { xmlns = "x-lxmppd:extensions:legacyauth" }); - elseif err == "auth" then - reply:tag("error", { code = "401", type = "auth" }) - :tag("not-authorized", { xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" }); - end - send(session, reply); - return true; - end - end - send(session, st.reply(stanza)); +local secure_auth_only = module:get_option("c2s_require_encryption") + or module:get_option("require_encryption") + or not(module:get_option("allow_unencrypted_plain_auth")); + +local sessionmanager = require "core.sessionmanager"; +local usermanager = require "core.usermanager"; +local nodeprep = require "util.encodings".stringprep.nodeprep; +local resourceprep = require "util.encodings".stringprep.resourceprep; + +module:add_feature("jabber:iq:auth"); +module:hook("stream-features", function(event) + local origin, features = event.origin, event.features; + if secure_auth_only and not origin.secure then + -- Sorry, not offering to insecure streams! + return; + elseif not origin.username then + features:tag("auth", {xmlns='http://jabber.org/features/iq-auth'}):up(); + end +end); + +module:hook("stanza/iq/jabber:iq:auth:query", function(event) + local session, stanza = event.origin, event.stanza; + + if session.type ~= "c2s_unauthed" then + (session.sends2s or session.send)(st.error_reply(stanza, "cancel", "service-unavailable", "Legacy authentication is only allowed for unauthenticated client connections.")); + return true; + end + + if secure_auth_only and not session.secure then + session.send(st.error_reply(stanza, "modify", "not-acceptable", "Encryption (SSL or TLS) is required to connect to this server")); + return true; + end + + local username = stanza.tags[1]:child_with_name("username"); + local password = stanza.tags[1]:child_with_name("password"); + local resource = stanza.tags[1]:child_with_name("resource"); + if not (username and password and resource) then + local reply = st.reply(stanza); + session.send(reply:query("jabber:iq:auth") + :tag("username"):up() + :tag("password"):up() + :tag("resource"):up()); + else + username, password, resource = t_concat(username), t_concat(password), t_concat(resource); + username = nodeprep(username); + resource = resourceprep(resource) + if not (username and resource) then + session.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + end + if usermanager.test_password(username, session.host, password) then + -- Authentication successful! + local success, err = sessionmanager.make_authenticated(session, username); + if success then + local err_type, err_msg; + success, err_type, err, err_msg = sessionmanager.bind_resource(session, resource); + if not success then + session.send(st.error_reply(stanza, err_type, err, err_msg)); + session.username, session.type = nil, "c2s_unauthed"; -- FIXME should this be placed in sessionmanager? return true; - else - local reply = st.reply(stanza); - reply.attr.type = "error"; - reply:tag("error", { code = "401", type = "auth" }) - :tag("not-authorized", { xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas" }); - dispatch_stanza(reply); + elseif resource ~= session.resource then -- server changed resource, not supported by legacy auth + session.send(st.error_reply(stanza, "cancel", "conflict", "The requested resource could not be assigned to this session.")); + session:close(); -- FIXME undo resource bind and auth instead of closing the session? return true; end end - - end);
\ No newline at end of file + session.send(st.reply(stanza)); + else + session.send(st.error_reply(stanza, "auth", "not-authorized")); + end + end + return true; +end); diff --git a/plugins/mod_message.lua b/plugins/mod_message.lua new file mode 100644 index 00000000..e85da613 --- /dev/null +++ b/plugins/mod_message.lua @@ -0,0 +1,85 @@ +-- 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. +-- + + +local full_sessions = prosody.full_sessions; +local bare_sessions = prosody.bare_sessions; + +local st = require "util.stanza"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local user_exists = require "core.usermanager".user_exists; + +local function process_to_bare(bare, origin, stanza) + local user = bare_sessions[bare]; + + local t = stanza.attr.type; + if t == "error" then + -- discard + elseif t == "groupchat" then + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + elseif t == "headline" then + if user and stanza.attr.to == bare then + for _, session in pairs(user.sessions) do + if session.presence and session.priority >= 0 then + session.send(stanza); + end + end + end -- current policy is to discard headlines if no recipient is available + else -- chat or normal message + if user then -- some resources are connected + local recipients = user.top_resources; + if recipients then + local sent; + for i=1,#recipients do + sent = recipients[i].send(stanza) or sent; + end + if sent then + return true; + end + end + end + -- no resources are online + local node, host = jid_split(bare); + local ok + if user_exists(node, host) then + -- TODO apply the default privacy list + + ok = module:fire_event('message/offline/handle', { + origin = origin, + stanza = stanza, + }); + end + + if not ok then + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + end + end + return true; +end + +module:hook("message/full", function(data) + -- message to full JID recieved + local origin, stanza = data.origin, data.stanza; + + local session = full_sessions[stanza.attr.to]; + if session and session.send(stanza) then + return true; + else -- resource not online + return process_to_bare(jid_bare(stanza.attr.to), origin, stanza); + end +end); + +module:hook("message/bare", function(data) + -- message to bare JID recieved + local origin, stanza = data.origin, data.stanza; + + return process_to_bare(stanza.attr.to or (origin.username..'@'..origin.host), origin, stanza); +end); + +module:add_feature("msgoffline"); diff --git a/plugins/mod_motd.lua b/plugins/mod_motd.lua new file mode 100644 index 00000000..ed78294b --- /dev/null +++ b/plugins/mod_motd.lua @@ -0,0 +1,30 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2010 Jeff Mitchell +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local host = module:get_host(); +local motd_text = module:get_option_string("motd_text"); +local motd_jid = module:get_option_string("motd_jid", host); + +if not motd_text then return; end + +local st = require "util.stanza"; + +motd_text = motd_text:gsub("^%s*(.-)%s*$", "%1"):gsub("\n%s+", "\n"); -- Strip indentation from the config + +module:hook("presence/bare", function (event) + local session, stanza = event.origin, event.stanza; + if session.username and not session.presence + and not stanza.attr.type and not stanza.attr.to then + local motd_stanza = + st.message({ to = session.full_jid, from = motd_jid }) + :tag("body"):text(motd_text); + module:send(motd_stanza); + module:log("debug", "MOTD send to user %s", session.full_jid); + end +end, 1); diff --git a/plugins/mod_net_multiplex.lua b/plugins/mod_net_multiplex.lua new file mode 100644 index 00000000..d666b907 --- /dev/null +++ b/plugins/mod_net_multiplex.lua @@ -0,0 +1,70 @@ +module:set_global(); + +local max_buffer_len = module:get_option_number("multiplex_buffer_size", 1024); + +local portmanager = require "core.portmanager"; + +local available_services = {}; + +local function add_service(service) + local multiplex_pattern = service.multiplex and service.multiplex.pattern; + if multiplex_pattern then + module:log("debug", "Adding multiplex service %q with pattern %q", service.name, multiplex_pattern); + available_services[service] = multiplex_pattern; + else + 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); + +for service_name, services in pairs(portmanager.get_registered_services()) do + for i, service in ipairs(services) do + add_service(service); + end +end + +local buffers = {}; + +local listener = { default_mode = "*a" }; + +function listener.onconnect() +end + +function listener.onincoming(conn, data) + if not data then return; end + local buf = buffers[conn]; + buffers[conn] = nil; + buf = buf and buf..data or data; + for service, multiplex_pattern in pairs(available_services) do + if buf:match(multiplex_pattern) then + module:log("debug", "Routing incoming connection to %s", service.name); + local listener = service.listener; + conn:setlistener(listener); + local onconnect = listener.onconnect; + if onconnect then onconnect(conn) end + return listener.onincoming(conn, buf); + end + end + if #buf > max_buffer_len then -- Give up + conn:close(); + else + buffers[conn] = buf; + end +end + +function listener.ondisconnect(conn, err) + buffers[conn] = nil; -- warn if no buffer? +end + +module:provides("net", { + name = "multiplex"; + config_prefix = ""; + listener = listener; +}); + +module:provides("net", { + name = "multiplex_ssl"; + config_prefix = "ssl"; + listener = listener; +}); diff --git a/plugins/mod_offline.lua b/plugins/mod_offline.lua new file mode 100644 index 00000000..1ac62f94 --- /dev/null +++ b/plugins/mod_offline.lua @@ -0,0 +1,51 @@ +-- Prosody IM +-- Copyright (C) 2008-2009 Matthew Wild +-- Copyright (C) 2008-2009 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + + +local datamanager = require "util.datamanager"; +local st = require "util.stanza"; +local datetime = require "util.datetime"; +local ipairs = ipairs; +local jid_split = require "util.jid".split; + +module:add_feature("msgoffline"); + +module:hook("message/offline/handle", function(event) + local origin, stanza = event.origin, event.stanza; + local to = stanza.attr.to; + local node, host; + if to then + node, host = jid_split(to) + else + node, host = origin.username, origin.host; + end + + stanza.attr.stamp, stanza.attr.stamp_legacy = datetime.datetime(), datetime.legacy(); + local result = datamanager.list_append(node, host, "offline", st.preserialize(stanza)); + stanza.attr.stamp, stanza.attr.stamp_legacy = nil, nil; + + return result; +end); + +module:hook("message/offline/broadcast", function(event) + local origin = event.origin; + + local node, host = origin.username, origin.host; + + local data = datamanager.list_load(node, host, "offline"); + if not data then return true; end + for _, stanza in ipairs(data) do + stanza = st.deserialize(stanza); + stanza:tag("delay", {xmlns = "urn:xmpp:delay", from = host, stamp = stanza.attr.stamp}):up(); -- XEP-0203 + stanza:tag("x", {xmlns = "jabber:x:delay", from = host, stamp = stanza.attr.stamp_legacy}):up(); -- XEP-0091 (deprecated) + stanza.attr.stamp, stanza.attr.stamp_legacy = nil, nil; + origin.send(stanza); + end + datamanager.list_store(node, host, "offline", nil); + return true; +end); diff --git a/plugins/mod_pep.lua b/plugins/mod_pep.lua new file mode 100644 index 00000000..a65ee903 --- /dev/null +++ b/plugins/mod_pep.lua @@ -0,0 +1,280 @@ +-- 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. +-- + + +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local st = require "util.stanza"; +local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; +local pairs = pairs; +local next = next; +local type = type; +local calculate_hash = require "util.caps".calculate_hash; +local core_post_stanza = prosody.core_post_stanza; + +local NULL = {}; +local data = {}; +local recipients = {}; +local hash_map = {}; + +module.save = function() + return { data = data, recipients = recipients, hash_map = hash_map }; +end +module.restore = function(state) + data = state.data or {}; + recipients = state.recipients or {}; + hash_map = state.hash_map or {}; +end + +module:add_identity("pubsub", "pep", module:get_option_string("name", "Prosody")); +module:add_feature("http://jabber.org/protocol/pubsub#publish"); + +local function subscription_presence(user_bare, recipient) + local recipient_bare = jid_bare(recipient); + if (recipient_bare == user_bare) then return true end + local username, host = jid_split(user_bare); + return is_contact_subscribed(username, host, recipient_bare); +end + +local function publish(session, node, id, item) + item.attr.xmlns = nil; + local disable = #item.tags ~= 1 or #item.tags[1] == 0; + if #item.tags == 0 then item.name = "retract"; end + local bare = session.username..'@'..session.host; + local stanza = st.message({from=bare, type='headline'}) + :tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'}) + :tag('items', {node=node}) + :add_child(item) + :up() + :up(); + + -- store for the future + local user_data = data[bare]; + if disable then + if user_data then + user_data[node] = nil; + if not next(user_data) then data[bare] = nil; end + end + else + if not user_data then user_data = {}; data[bare] = user_data; end + user_data[node] = {id or "1", item}; + end + + -- broadcast + for recipient, notify in pairs(recipients[bare] or NULL) do + if notify[node] then + stanza.attr.to = recipient; + core_post_stanza(session, stanza); + end + end +end +local function publish_all(user, recipient, session) + local d = data[user]; + local notify = recipients[user] and recipients[user][recipient]; + if d and notify then + for node in pairs(notify) do + if d[node] then + 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'}) + :tag('items', {node=node}) + :add_child(item) + :up() + :up()); + end + end + end +end + +local function get_caps_hash_from_presence(stanza, current) + local t = stanza.attr.type; + if not t then + for _, child in pairs(stanza.tags) do + if child.name == "c" and child.attr.xmlns == "http://jabber.org/protocol/caps" then + local attr = child.attr; + if attr.hash then -- new caps + if attr.hash == 'sha-1' and attr.node and attr.ver then return attr.ver, attr.node.."#"..attr.ver; end + else -- legacy caps + if attr.node and attr.ver then return attr.node.."#"..attr.ver.."#"..(attr.ext or ""), attr.node.."#"..attr.ver; end + end + return; -- bad caps format + end + end + elseif t == "unavailable" or t == "error" then + return; + end + return current; -- no caps, could mean caps optimization, so return current +end + +module:hook("presence/bare", function(event) + -- inbound presence to bare JID recieved + local origin, stanza = event.origin, event.stanza; + local user = stanza.attr.to or (origin.username..'@'..origin.host); + local t = stanza.attr.type; + local self = not stanza.attr.to; + + if not t then -- available presence + if self or subscription_presence(user, stanza.attr.from) then + local recipient = stanza.attr.from; + local current = recipients[user] and recipients[user][recipient]; + local hash = get_caps_hash_from_presence(stanza, current); + if current == hash or (current and current == hash_map[hash]) then return; end + if not hash then + if recipients[user] then recipients[user][recipient] = nil; end + else + recipients[user] = recipients[user] or {}; + if hash_map[hash] then + recipients[user][recipient] = hash_map[hash]; + publish_all(user, recipient, origin); + else + recipients[user][recipient] = hash; + local from_bare = origin.type == "c2s" and origin.username.."@"..origin.host; + if self or origin.type ~= "c2s" or (recipients[from_bare] and recipients[from_bare][origin.full_jid]) ~= hash then + -- COMPAT from ~= stanza.attr.to because OneTeam can't deal with missing from attribute + origin.send( + st.stanza("iq", {from=user, to=stanza.attr.from, id="disco", type="get"}) + :query("http://jabber.org/protocol/disco#info") + ); + end + end + end + end + elseif t == "unavailable" then + if recipients[user] then recipients[user][stanza.attr.from] = nil; end + elseif not self and t == "unsubscribe" then + local from = jid_bare(stanza.attr.from); + local subscriptions = recipients[user]; + if subscriptions then + for subscriber in pairs(subscriptions) do + if jid_bare(subscriber) == from then + recipients[user][subscriber] = nil; + end + end + end + end +end, 10); + +module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function(event) + local session, stanza = event.origin, event.stanza; + local payload = stanza.tags[1]; + + if stanza.attr.type == 'set' and (not stanza.attr.to or jid_bare(stanza.attr.from) == stanza.attr.to) then + payload = payload.tags[1]; + if payload and (payload.name == 'publish' or payload.name == 'retract') and payload.attr.node then -- <publish node='http://jabber.org/protocol/tune'> + local node = payload.attr.node; + payload = payload.tags[1]; + if payload and payload.name == "item" then -- <item> + local id = payload.attr.id; + session.send(st.reply(stanza)); + publish(session, node, id, st.clone(payload)); + return true; + end + end + elseif stanza.attr.type == 'get' then + local user = stanza.attr.to and jid_bare(stanza.attr.to) or session.username..'@'..session.host; + if subscription_presence(user, stanza.attr.from) then + local user_data = data[user]; + local node, requested_id; + payload = payload.tags[1]; + if payload and payload.name == 'items' then + node = payload.attr.node; + local item = payload.tags[1]; + if item and item.name == "item" then + requested_id = item.attr.id; + end + end + if node and user_data and user_data[node] then -- Send the last item + local id, item = unpack(user_data[node]); + if not requested_id or id == requested_id then + local stanza = st.reply(stanza) + :tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'}) + :tag('items', {node=node}) + :add_child(item) + :up() + :up(); + session.send(stanza); + return true; + else -- requested item doesn't exist + local stanza = st.reply(stanza) + :tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'}) + :tag('items', {node=node}) + :up(); + session.send(stanza); + return true; + end + elseif node then -- node doesn't exist + session.send(st.error_reply(stanza, 'cancel', 'item-not-found')); + return true; + else --invalid request + session.send(st.error_reply(stanza, 'modify', 'bad-request')); + 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'})); + return true; + end + end +end); + +module:hook("iq-result/bare/disco", function(event) + local session, stanza = event.origin, event.stanza; + if stanza.attr.type == "result" then + local disco = stanza.tags[1]; + if disco and disco.name == "query" and disco.attr.xmlns == "http://jabber.org/protocol/disco#info" then + -- Process disco response + local self = not stanza.attr.to; + local user = stanza.attr.to or (session.username..'@'..session.host); + local contact = stanza.attr.from; + local current = recipients[user] and recipients[user][contact]; + if type(current) ~= "string" then return; end -- check if waiting for recipient's response + local ver = current; + if not string.find(current, "#") then + ver = calculate_hash(disco.tags); -- calculate hash + end + local notify = {}; + for _, feature in pairs(disco.tags) do + if feature.name == "feature" and feature.attr.var then + local nfeature = feature.attr.var:match("^(.*)%+notify$"); + if nfeature then notify[nfeature] = true; end + end + end + hash_map[ver] = notify; -- update hash map + if self then + for jid, item in pairs(session.roster) do -- for all interested contacts + if item.subscription == "both" or item.subscription == "from" then + if not recipients[jid] then recipients[jid] = {}; end + recipients[jid][contact] = notify; + publish_all(jid, contact, session); + end + end + end + recipients[user][contact] = notify; -- set recipient's data to calculated data + -- send messages to recipient + publish_all(user, contact, session); + end + end +end); + +module:hook("account-disco-info", function(event) + local stanza = event.stanza; + stanza:tag('identity', {category='pubsub', type='pep'}):up(); + stanza:tag('feature', {var='http://jabber.org/protocol/pubsub#publish'}):up(); +end); + +module:hook("account-disco-items", function(event) + local stanza = event.stanza; + local bare = stanza.attr.to; + local user_data = data[bare]; + + if user_data then + for node, _ in pairs(user_data) do + stanza:tag('item', {jid=bare, node=node}):up(); -- TODO we need to handle queries to these nodes + end + end +end); diff --git a/plugins/mod_ping.lua b/plugins/mod_ping.lua new file mode 100644 index 00000000..0bfcac66 --- /dev/null +++ b/plugins/mod_ping.lua @@ -0,0 +1,35 @@ +-- 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. +-- + +local st = require "util.stanza"; + +module:add_feature("urn:xmpp:ping"); + +local function ping_handler(event) + if event.stanza.attr.type == "get" then + event.origin.send(st.reply(event.stanza)); + return true; + end +end + +module:hook("iq/bare/urn:xmpp:ping:ping", ping_handler); +module:hook("iq/host/urn:xmpp:ping:ping", ping_handler); + +-- Ad-hoc command + +local datetime = require "util.datetime".datetime; + +function ping_command_handler (self, data, state) + local now = datetime(); + return { info = "Pong\n"..now, status = "completed" }; +end + +local adhoc_new = module:require "adhoc".new; +local descriptor = adhoc_new("Ping", "ping", ping_command_handler); +module:add_item ("adhoc", descriptor); + diff --git a/plugins/mod_posix.lua b/plugins/mod_posix.lua new file mode 100644 index 00000000..28fd7f38 --- /dev/null +++ b/plugins/mod_posix.lua @@ -0,0 +1,193 @@ +-- 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. +-- + + +local want_pposix_version = "0.3.6"; + +local pposix = assert(require "util.pposix"); +if pposix._VERSION ~= want_pposix_version then + module:log("warn", "Unknown version (%s) of binary pposix module, expected %s. Perhaps you need to recompile?", tostring(pposix._VERSION), want_pposix_version); +end + +local signal = select(2, pcall(require, "util.signal")); +if type(signal) == "string" then + module:log("warn", "Couldn't load signal library, won't respond to SIGTERM"); +end + +local lfs = require "lfs"; +local stat = lfs.attributes; + +local prosody = _G.prosody; + +module:set_global(); -- we're a global module + +local umask = module:get_option("umask") or "027"; +pposix.umask(umask); + +-- Allow switching away from root, some people like strange ports. +module:hook("server-started", function () + local uid = module:get_option("setuid"); + local gid = module:get_option("setgid"); + if gid then + local success, msg = pposix.setgid(gid); + if success then + module:log("debug", "Changed group to %s successfully.", gid); + else + module:log("error", "Failed to change group to %s. Error: %s", gid, msg); + prosody.shutdown("Failed to change group to %s", gid); + end + end + if uid then + local success, msg = pposix.setuid(uid); + if success then + module:log("debug", "Changed user to %s successfully.", uid); + else + module:log("error", "Failed to change user to %s. Error: %s", uid, msg); + prosody.shutdown("Failed to change user to %s", uid); + end + end + end); + +-- Don't even think about it! +if not prosody.start_time then -- server-starting + local suid = module:get_option("setuid"); + if not suid or suid == 0 or suid == "root" then + if pposix.getuid() == 0 and not module:get_option("run_as_root") then + module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!"); + module:log("error", "For more information on running Prosody as root, see http://prosody.im/doc/root"); + prosody.shutdown("Refusing to run as root"); + end + end +end + +local pidfile; +local pidfile_handle; + +local function remove_pidfile() + if pidfile_handle then + pidfile_handle:close(); + os.remove(pidfile); + pidfile, pidfile_handle = nil, nil; + end +end + +local function write_pidfile() + if pidfile_handle then + remove_pidfile(); + end + pidfile = module:get_option("pidfile"); + if pidfile then + local err; + local mode = stat(pidfile) and "r+" or "w+"; + pidfile_handle, err = io.open(pidfile, mode); + if not pidfile_handle then + module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err); + prosody.shutdown("Couldn't write pidfile"); + else + if not lfs.lock(pidfile_handle, "w") then -- Exclusive lock + local other_pid = pidfile_handle:read("*a"); + module:log("error", "Another Prosody instance seems to be running with PID %s, quitting", other_pid); + pidfile_handle = nil; + prosody.shutdown("Prosody already running"); + else + pidfile_handle:close(); + pidfile_handle, err = io.open(pidfile, "w+"); + if not pidfile_handle then + module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err); + prosody.shutdown("Couldn't write pidfile"); + else + if lfs.lock(pidfile_handle, "w") then + pidfile_handle:write(tostring(pposix.getpid())); + pidfile_handle:flush(); + end + end + end + end + end +end + +local syslog_opened; +function syslog_sink_maker(config) + if not syslog_opened then + pposix.syslog_open("prosody", module:get_option_string("syslog_facility")); + syslog_opened = true; + end + local syslog, format = pposix.syslog_log, string.format; + return function (name, level, message, ...) + if ... then + syslog(level, name, format(message, ...)); + else + syslog(level, name, message); + end + end; +end +require "core.loggingmanager".register_sink_type("syslog", syslog_sink_maker); + +local daemonize = module:get_option("daemonize"); +if daemonize == nil then + local no_daemonize = module:get_option("no_daemonize"); --COMPAT w/ 0.5 + daemonize = not no_daemonize; + if no_daemonize ~= nil then + module:log("warn", "The 'no_daemonize' option is now replaced by 'daemonize'"); + module:log("warn", "Update your config from 'no_daemonize = %s' to 'daemonize = %s'", tostring(no_daemonize), tostring(daemonize)); + end +end + +local function remove_log_sinks() + local lm = require "core.loggingmanager"; + lm.register_sink_type("console", nil); + lm.register_sink_type("stdout", nil); + lm.reload_logging(); +end + +if daemonize then + local function daemonize_server() + module:log("info", "Prosody is about to detach from the console, disabling further console output"); + remove_log_sinks(); + local ok, ret = pposix.daemonize(); + if not ok then + module:log("error", "Failed to daemonize: %s", ret); + elseif ret and ret > 0 then + os.exit(0); + else + module:log("info", "Successfully daemonized to PID %d", pposix.getpid()); + write_pidfile(); + end + end + if not prosody.start_time then -- server-starting + daemonize_server(); + end +else + -- Not going to daemonize, so write the pid of this process + write_pidfile(); +end + +module:hook("server-stopped", remove_pidfile); + +-- Set signal handlers +if signal.signal then + signal.signal("SIGTERM", function () + module:log("warn", "Received SIGTERM"); + prosody.unlock_globals(); + prosody.shutdown("Received SIGTERM"); + prosody.lock_globals(); + end); + + signal.signal("SIGHUP", function () + module:log("info", "Received SIGHUP"); + prosody.reload_config(); + prosody.reopen_logfiles(); + end); + + signal.signal("SIGINT", function () + module:log("info", "Received SIGINT"); + prosody.unlock_globals(); + prosody.shutdown("Received SIGINT"); + prosody.lock_globals(); + end); +end diff --git a/plugins/mod_presence.lua b/plugins/mod_presence.lua new file mode 100644 index 00000000..8dac2d35 --- /dev/null +++ b/plugins/mod_presence.lua @@ -0,0 +1,382 @@ +-- 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. +-- + +local log = module._log; + +local require = require; +local pairs = pairs; +local t_concat, t_insert = table.concat, table.insert; +local s_find = string.find; +local tonumber = tonumber; + +local core_post_stanza = prosody.core_post_stanza; +local st = require "util.stanza"; +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local datetime = require "util.datetime"; +local hosts = prosody.hosts; +local bare_sessions = prosody.bare_sessions; +local full_sessions = prosody.full_sessions; +local NULL = {}; + +local rostermanager = require "core.rostermanager"; +local sessionmanager = require "core.sessionmanager"; + +local function select_top_resources(user) + local priority = 0; + local recipients = {}; + for _, session in pairs(user.sessions) do -- find resource with greatest priority + if session.presence then + -- TODO check active privacy list for session + local p = session.priority; + if p > priority then + priority = p; + recipients = {session}; + elseif p == priority then + t_insert(recipients, session); + end + end + end + return recipients; +end +local function recalc_resource_map(user) + if user then + user.top_resources = select_top_resources(user); + if #user.top_resources == 0 then user.top_resources = nil; end + end +end + +local ignore_presence_priority = module:get_option("ignore_presence_priority"); + +function handle_normal_presence(origin, stanza) + if ignore_presence_priority then + local priority = stanza:child_with_name("priority"); + if priority and priority[1] ~= "0" then + for i=#priority.tags,1,-1 do priority.tags[i] = nil; end + for i=#priority,1,-1 do priority[i] = nil; end + priority[1] = "0"; + end + end + local priority = stanza:child_with_name("priority"); + if priority and #priority > 0 then + priority = t_concat(priority); + if s_find(priority, "^[+-]?[0-9]+$") then + priority = tonumber(priority); + if priority < -128 then priority = -128 end + if priority > 127 then priority = 127 end + else priority = 0; end + else priority = 0; end + if full_sessions[origin.full_jid] then -- if user is still connected + origin.send(stanza); -- reflect their presence back to them + end + local roster = origin.roster; + local node, host = origin.username, origin.host; + local user = bare_sessions[node.."@"..host]; + for _, res in pairs(user and user.sessions or NULL) do -- broadcast to all resources + if res ~= origin and res.presence then -- to resource + stanza.attr.to = res.full_jid; + core_post_stanza(origin, stanza, true); + end + end + for jid, item in pairs(roster) do -- broadcast to all interested contacts + if item.subscription == "both" or item.subscription == "from" then + stanza.attr.to = jid; + core_post_stanza(origin, stanza, true); + end + end + if stanza.attr.type == nil and not origin.presence then -- initial presence + origin.presence = stanza; -- FIXME repeated later + local probe = st.presence({from = origin.full_jid, type = "probe"}); + for jid, item in pairs(roster) do -- probe all contacts we are subscribed to + if item.subscription == "both" or item.subscription == "to" then + probe.attr.to = jid; + core_post_stanza(origin, probe, true); + end + end + for _, res in pairs(user and user.sessions or NULL) do -- broadcast from all available resources + if res ~= origin and res.presence then + res.presence.attr.to = origin.full_jid; + core_post_stanza(res, res.presence, true); + res.presence.attr.to = nil; + end + end + if roster.pending then -- resend incoming subscription requests + for jid in pairs(roster.pending) do + origin.send(st.presence({type="subscribe", from=jid})); -- TODO add to attribute? Use original? + end + end + local request = st.presence({type="subscribe", from=origin.username.."@"..origin.host}); + for jid, item in pairs(roster) do -- resend outgoing subscription requests + if item.ask then + request.attr.to = jid; + core_post_stanza(origin, request, true); + end + end + + if priority >= 0 then + local event = { origin = origin } + module:fire_event('message/offline/broadcast', event); + end + end + if stanza.attr.type == "unavailable" then + origin.presence = nil; + if origin.priority then + origin.priority = nil; + recalc_resource_map(user); + end + if origin.directed then + for jid in pairs(origin.directed) do + stanza.attr.to = jid; + core_post_stanza(origin, stanza, true); + end + origin.directed = nil; + end + else + origin.presence = stanza; + stanza:tag("delay", { xmlns = "urn:xmpp:delay", from = host, stamp = datetime.datetime() }):up(); + if origin.priority ~= priority then + origin.priority = priority; + recalc_resource_map(user); + end + end + stanza.attr.to = nil; -- reset it +end + +function send_presence_of_available_resources(user, host, jid, recipient_session, stanza) + local h = hosts[host]; + local count = 0; + if h and h.type == "local" then + local u = h.sessions[user]; + if u then + for k, session in pairs(u.sessions) do + local pres = session.presence; + if pres then + if stanza then pres = stanza; pres.attr.from = session.full_jid; end + pres.attr.to = jid; + core_post_stanza(session, pres, true); + pres.attr.to = nil; + count = count + 1; + end + end + end + end + log("debug", "broadcasted presence of %d resources from %s@%s to %s", count, user, host, jid); + return count; +end + +function handle_outbound_presence_subscriptions_and_probes(origin, stanza, from_bare, to_bare) + local node, host = jid_split(from_bare); + if to_bare == from_bare then return; end -- No self contacts + local st_from, st_to = stanza.attr.from, stanza.attr.to; + stanza.attr.from, stanza.attr.to = from_bare, to_bare; + log("debug", "outbound presence %s from %s for %s", stanza.attr.type, from_bare, to_bare); + if stanza.attr.type == "probe" then + stanza.attr.from, stanza.attr.to = st_from, st_to; + return; + elseif stanza.attr.type == "subscribe" then + -- 1. route stanza + -- 2. roster push (subscription = none, ask = subscribe) + if rostermanager.set_contact_pending_out(node, host, to_bare) then + rostermanager.roster_push(node, host, to_bare); + end -- else file error + core_post_stanza(origin, stanza); + elseif stanza.attr.type == "unsubscribe" then + -- 1. route stanza + -- 2. roster push (subscription = none or from) + if rostermanager.unsubscribe(node, host, to_bare) then + rostermanager.roster_push(node, host, to_bare); -- FIXME do roster push when roster has in fact not changed? + end -- else file error + core_post_stanza(origin, stanza); + elseif stanza.attr.type == "subscribed" then + -- 1. route stanza + -- 2. roster_push () + -- 3. send_presence_of_available_resources + 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); + elseif stanza.attr.type == "unsubscribed" then + -- 1. send unavailable + -- 2. route stanza + -- 3. roster push (subscription = from or both) + local success, pending_in, subscribed = rostermanager.unsubscribed(node, host, to_bare); + if success then + if subscribed then + rostermanager.roster_push(node, host, to_bare); + end + core_post_stanza(origin, stanza); + if subscribed then + send_presence_of_available_resources(node, host, to_bare, origin, st.presence({ type = "unavailable" })); + end + end + else + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid presence type")); + end + stanza.attr.from, stanza.attr.to = st_from, st_to; + return true; +end + +function handle_inbound_presence_subscriptions_and_probes(origin, stanza, from_bare, to_bare) + local node, host = jid_split(to_bare); + local st_from, st_to = stanza.attr.from, stanza.attr.to; + stanza.attr.from, stanza.attr.to = from_bare, to_bare; + log("debug", "inbound presence %s from %s for %s", stanza.attr.type, from_bare, to_bare); + + if stanza.attr.type == "probe" then + local result, err = rostermanager.is_contact_subscribed(node, host, from_bare); + if result then + if 0 == send_presence_of_available_resources(node, host, st_from, origin) then + core_post_stanza(hosts[host], st.presence({from=to_bare, to=st_from, type="unavailable"}), true); -- TODO send last activity + end + elseif not err then + core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unsubscribed"}), true); + end + elseif stanza.attr.type == "subscribe" then + if rostermanager.is_contact_subscribed(node, host, from_bare) then + core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="subscribed"}), true); -- already subscribed + -- Sending presence is not clearly stated in the RFC, but it seems appropriate + 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 + 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 + sessionmanager.send_to_available_resources(node, host, stanza); + end -- TODO else return error, unable to save + end + end + elseif stanza.attr.type == "unsubscribe" then + if rostermanager.process_inbound_unsubscribe(node, host, from_bare) then + sessionmanager.send_to_interested_resources(node, host, stanza); + rostermanager.roster_push(node, host, from_bare); + end + elseif stanza.attr.type == "subscribed" then + if rostermanager.process_inbound_subscription_approval(node, host, from_bare) then + sessionmanager.send_to_interested_resources(node, host, stanza); + rostermanager.roster_push(node, host, from_bare); + end + elseif stanza.attr.type == "unsubscribed" then + if rostermanager.process_inbound_subscription_cancellation(node, host, from_bare) then + sessionmanager.send_to_interested_resources(node, host, stanza); + rostermanager.roster_push(node, host, from_bare); + end + else + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid presence type")); + end + stanza.attr.from, stanza.attr.to = st_from, st_to; + return true; +end + +local outbound_presence_handler = function(data) + -- outbound presence recieved + local origin, stanza = data.origin, data.stanza; + + local to = stanza.attr.to; + if to then + local t = stanza.attr.type; + if t ~= nil and t ~= "unavailable" and t ~= "error" then -- check for subscriptions and probes + return handle_outbound_presence_subscriptions_and_probes(origin, stanza, jid_bare(stanza.attr.from), jid_bare(stanza.attr.to)); + end + + local to_bare = jid_bare(to); + local roster = origin.roster; + if roster and not(roster[to_bare] and (roster[to_bare].subscription == "both" or roster[to_bare].subscription == "from")) then -- directed presence + origin.directed = origin.directed or {}; + if t then -- removing from directed presence list on sending an error or unavailable + origin.directed[to] = nil; -- FIXME does it make more sense to add to_bare rather than to? + else + origin.directed[to] = true; -- FIXME does it make more sense to add to_bare rather than to? + end + end + end -- TODO maybe handle normal presence here, instead of letting it pass to incoming handlers? +end + +module:hook("pre-presence/full", outbound_presence_handler); +module:hook("pre-presence/bare", outbound_presence_handler); +module:hook("pre-presence/host", outbound_presence_handler); + +module:hook("presence/bare", function(data) + -- inbound presence to bare JID recieved + local origin, stanza = data.origin, data.stanza; + + local to = stanza.attr.to; + local t = stanza.attr.type; + if to then + if t ~= nil and t ~= "unavailable" and t ~= "error" then -- check for subscriptions and probes sent to bare JID + return handle_inbound_presence_subscriptions_and_probes(origin, stanza, jid_bare(stanza.attr.from), jid_bare(stanza.attr.to)); + end + + local user = bare_sessions[to]; + if user then + for _, session in pairs(user.sessions) do + if session.presence then -- only send to available resources + session.send(stanza); + end + end + end -- no resources not online, discard + elseif not t or t == "unavailable" then + handle_normal_presence(origin, stanza); + else + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid presence type")); + end + return true; +end); +module:hook("presence/full", function(data) + -- inbound presence to full JID recieved + local origin, stanza = data.origin, data.stanza; + + local t = stanza.attr.type; + if t ~= nil and t ~= "unavailable" and t ~= "error" then -- check for subscriptions and probes sent to full JID + return handle_inbound_presence_subscriptions_and_probes(origin, stanza, jid_bare(stanza.attr.from), jid_bare(stanza.attr.to)); + end + + local session = full_sessions[stanza.attr.to]; + if session then + -- TODO fire post processing event + session.send(stanza); + end -- resource not online, discard + return true; +end); +module:hook("presence/host", function(data) + -- inbound presence to the host + local stanza = data.stanza; + + local from_bare = jid_bare(stanza.attr.from); + local t = stanza.attr.type; + if t == "probe" then + core_post_stanza(hosts[module.host], st.presence({ from = module.host, to = from_bare, id = stanza.attr.id })); + elseif t == "subscribe" then + core_post_stanza(hosts[module.host], st.presence({ from = module.host, to = from_bare, id = stanza.attr.id, type = "subscribed" })); + core_post_stanza(hosts[module.host], st.presence({ from = module.host, to = from_bare, id = stanza.attr.id })); + end + return true; +end); + +module:hook("resource-unbind", function(event) + local session, err = event.session, event.error; + -- Send unavailable presence + if session.presence then + local pres = st.presence{ type = "unavailable" }; + if err then + pres:tag("status"):text("Disconnected: "..err):up(); + end + session:dispatch_stanza(pres); + elseif session.directed then + local pres = st.presence{ type = "unavailable", from = session.full_jid }; + if err then + pres:tag("status"):text("Disconnected: "..err):up(); + end + for jid in pairs(session.directed) do + pres.attr.to = jid; + core_post_stanza(session, pres, true); + end + session.directed = nil; + end +end); diff --git a/plugins/mod_privacy.lua b/plugins/mod_privacy.lua new file mode 100644 index 00000000..31ace9f9 --- /dev/null +++ b/plugins/mod_privacy.lua @@ -0,0 +1,448 @@ +-- Prosody IM +-- Copyright (C) 2009-2010 Matthew Wild +-- Copyright (C) 2009-2010 Waqas Hussain +-- Copyright (C) 2009 Thilo Cestonaro +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +module:add_feature("jabber:iq:privacy"); + +local st = require "util.stanza"; +local bare_sessions, full_sessions = prosody.bare_sessions, prosody.full_sessions; +local util_Jid = require "util.jid"; +local jid_bare = util_Jid.bare; +local jid_split, jid_join = util_Jid.split, util_Jid.join; +local load_roster = require "core.rostermanager".load_roster; +local to_number = tonumber; + +local privacy_storage = module:open_store(); + +function isListUsed(origin, name, privacy_lists) + local user = bare_sessions[origin.username.."@"..origin.host]; + if user then + for resource, session in pairs(user.sessions) do + if resource ~= origin.resource then + if session.activePrivacyList == name then + return true; + elseif session.activePrivacyList == nil and privacy_lists.default == name then + return true; + end + end + end + end +end + +function isAnotherSessionUsingDefaultList(origin) + local user = bare_sessions[origin.username.."@"..origin.host]; + if user then + for resource, session in pairs(user.sessions) do + if resource ~= origin.resource and session.activePrivacyList == nil then + return true; + end + end + end +end + +function declineList(privacy_lists, origin, stanza, which) + if which == "default" then + if isAnotherSessionUsingDefaultList(origin) then + return { "cancel", "conflict", "Another session is online and using the default list."}; + end + privacy_lists.default = nil; + origin.send(st.reply(stanza)); + elseif which == "active" then + origin.activePrivacyList = nil; + origin.send(st.reply(stanza)); + else + return {"modify", "bad-request", "Neither default nor active list specifed to decline."}; + end + return true; +end + +function activateList(privacy_lists, origin, stanza, which, name) + local list = privacy_lists.lists[name]; + + if which == "default" and list then + if isAnotherSessionUsingDefaultList(origin) then + return {"cancel", "conflict", "Another session is online and using the default list."}; + end + privacy_lists.default = name; + origin.send(st.reply(stanza)); + elseif which == "active" and list then + origin.activePrivacyList = name; + origin.send(st.reply(stanza)); + elseif not list then + return {"cancel", "item-not-found", "No such list: "..name}; + else + return {"modify", "bad-request", "No list chosen to be active or default."}; + end + return true; +end + +function deleteList(privacy_lists, origin, stanza, name) + local list = privacy_lists.lists[name]; + + if list then + if isListUsed(origin, name, privacy_lists) then + return {"cancel", "conflict", "Another session is online and using the list which should be deleted."}; + end + if privacy_lists.default == name then + privacy_lists.default = nil; + end + if origin.activePrivacyList == name then + origin.activePrivacyList = nil; + end + privacy_lists.lists[name] = nil; + origin.send(st.reply(stanza)); + return true; + end + return {"modify", "bad-request", "Not existing list specifed to be deleted."}; +end + +function createOrReplaceList (privacy_lists, origin, stanza, name, entries) + local bare_jid = origin.username.."@"..origin.host; + + if privacy_lists.lists == nil then + privacy_lists.lists = {}; + end + + local list = {}; + privacy_lists.lists[name] = list; + + local orderCheck = {}; + list.name = name; + list.items = {}; + + for _,item in ipairs(entries) do + if to_number(item.attr.order) == nil or to_number(item.attr.order) < 0 or orderCheck[item.attr.order] ~= nil then + return {"modify", "bad-request", "Order attribute not valid."}; + end + + if item.attr.type ~= nil and item.attr.type ~= "jid" and item.attr.type ~= "subscription" and item.attr.type ~= "group" then + return {"modify", "bad-request", "Type attribute not valid."}; + end + + local tmp = {}; + orderCheck[item.attr.order] = true; + + tmp["type"] = item.attr.type; + tmp["value"] = item.attr.value; + tmp["action"] = item.attr.action; + tmp["order"] = to_number(item.attr.order); + tmp["presence-in"] = false; + tmp["presence-out"] = false; + tmp["message"] = false; + tmp["iq"] = false; + + if #item.tags > 0 then + for _,tag in ipairs(item.tags) do + tmp[tag.name] = true; + end + end + + if tmp.type == "subscription" then + if tmp.value ~= "both" and + tmp.value ~= "to" and + tmp.value ~= "from" and + tmp.value ~= "none" then + return {"cancel", "bad-request", "Subscription value must be both, to, from or none."}; + end + end + + if tmp.action ~= "deny" and tmp.action ~= "allow" then + return {"cancel", "bad-request", "Action must be either deny or allow."}; + end + list.items[#list.items + 1] = tmp; + end + + table.sort(list, function(a, b) return a.order < b.order; end); + + origin.send(st.reply(stanza)); + if bare_sessions[bare_jid] ~= nil then + local iq = st.iq ( { type = "set", id="push1" } ); + iq:tag ("query", { xmlns = "jabber:iq:privacy" } ); + iq:tag ("list", { name = list.name } ):up(); + iq:up(); + for resource, session in pairs(bare_sessions[bare_jid].sessions) do + iq.attr.to = bare_jid.."/"..resource + session.send(iq); + end + else + return {"cancel", "bad-request", "internal error."}; + end + return true; +end + +function getList(privacy_lists, origin, stanza, name) + local reply = st.reply(stanza); + reply:tag("query", {xmlns="jabber:iq:privacy"}); + + if name == nil then + if privacy_lists.lists then + if origin.activePrivacyList then + reply:tag("active", {name=origin.activePrivacyList}):up(); + end + if privacy_lists.default then + reply:tag("default", {name=privacy_lists.default}):up(); + end + for name,list in pairs(privacy_lists.lists) do + reply:tag("list", {name=name}):up(); + end + end + else + local list = privacy_lists.lists[name]; + if list then + reply = reply:tag("list", {name=list.name}); + for _,item in ipairs(list.items) do + reply:tag("item", {type=item.type, value=item.value, action=item.action, order=item.order}); + if item["message"] then reply:tag("message"):up(); end + if item["iq"] then reply:tag("iq"):up(); end + if item["presence-in"] then reply:tag("presence-in"):up(); end + if item["presence-out"] then reply:tag("presence-out"):up(); end + reply:up(); + end + else + return {"cancel", "item-not-found", "Unknown list specified."}; + end + end + + origin.send(reply); + return true; +end + +module:hook("iq/bare/jabber:iq:privacy:query", function(data) + local origin, stanza = data.origin, data.stanza; + + if stanza.attr.to == nil then -- only service requests to own bare JID + local query = stanza.tags[1]; -- the query element + local valid = false; + local privacy_lists = privacy_storage:get(origin.username) or { lists = {} }; + + if privacy_lists.lists[1] then -- Code to migrate from old privacy lists format, remove in 0.8 + module:log("info", "Upgrading format of stored privacy lists for %s@%s", origin.username, origin.host); + local lists = privacy_lists.lists; + for idx, list in ipairs(lists) do + lists[list.name] = list; + lists[idx] = nil; + end + end + + if stanza.attr.type == "set" then + if #query.tags == 1 then -- the <query/> element MUST NOT include more than one child element + for _,tag in ipairs(query.tags) do + if tag.name == "active" or tag.name == "default" then + if tag.attr.name == nil then -- Client declines the use of active / default list + valid = declineList(privacy_lists, origin, stanza, tag.name); + else -- Client requests change of active / default list + valid = activateList(privacy_lists, origin, stanza, tag.name, tag.attr.name); + end + elseif tag.name == "list" and tag.attr.name then -- Client adds / edits a privacy list + if #tag.tags == 0 then -- Client removes a privacy list + valid = deleteList(privacy_lists, origin, stanza, tag.attr.name); + else -- Client edits a privacy list + valid = createOrReplaceList(privacy_lists, origin, stanza, tag.attr.name, tag.tags); + end + end + end + end + elseif stanza.attr.type == "get" then + local name = nil; + local listsToRetrieve = 0; + if #query.tags >= 1 then + for _,tag in ipairs(query.tags) do + if tag.name == "list" then -- Client requests a privacy list from server + name = tag.attr.name; + listsToRetrieve = listsToRetrieve + 1; + end + end + end + if listsToRetrieve == 0 or listsToRetrieve == 1 then + valid = getList(privacy_lists, origin, stanza, name); + end + end + + if valid ~= true then + valid = valid or { "cancel", "bad-request", "Couldn't understand request" }; + if valid[1] == nil then + valid[1] = "cancel"; + end + if valid[2] == nil then + valid[2] = "bad-request"; + end + origin.send(st.error_reply(stanza, valid[1], valid[2], valid[3])); + else + privacy_storage:set(origin.username, privacy_lists); + end + return true; + end +end); + +function checkIfNeedToBeBlocked(e, session) + local origin, stanza = e.origin, e.stanza; + local privacy_lists = privacy_storage:get(session.username) or {}; + local bare_jid = session.username.."@"..session.host; + local to = stanza.attr.to or bare_jid; + local from = stanza.attr.from; + + local is_to_user = bare_jid == jid_bare(to); + local is_from_user = bare_jid == jid_bare(from); + + --module:log("debug", "stanza: %s, to: %s, from: %s", tostring(stanza.name), tostring(to), tostring(from)); + + if privacy_lists.lists == nil or + not (session.activePrivacyList or privacy_lists.default) + then + return; -- Nothing to block, default is Allow all + end + if is_from_user and is_to_user then + --module:log("debug", "Not blocking communications between user's resources"); + return; -- from one of a user's resource to another => HANDS OFF! + end + + local listname = session.activePrivacyList; + if listname == nil then + listname = privacy_lists.default; -- no active list selected, use default list + end + local list = privacy_lists.lists[listname]; + if not list then -- should never happen + module:log("warn", "given privacy list not found. name: %s for user %s", listname, bare_jid); + return; + end + for _,item in ipairs(list.items) do + local apply = false; + local block = false; + if ( + (stanza.name == "message" and item.message) or + (stanza.name == "iq" and item.iq) or + (stanza.name == "presence" and is_to_user and item["presence-in"]) or + (stanza.name == "presence" and is_from_user and item["presence-out"]) or + (item.message == false and item.iq == false and item["presence-in"] == false and item["presence-out"] == false) + ) then + apply = true; + end + if apply then + local evilJid = {}; + apply = false; + if is_to_user then + --module:log("debug", "evil jid is (from): %s", from); + evilJid.node, evilJid.host, evilJid.resource = jid_split(from); + else + --module:log("debug", "evil jid is (to): %s", to); + evilJid.node, evilJid.host, evilJid.resource = jid_split(to); + end + if item.type == "jid" and + (evilJid.node and evilJid.host and evilJid.resource and item.value == evilJid.node.."@"..evilJid.host.."/"..evilJid.resource) or + (evilJid.node and evilJid.host and item.value == evilJid.node.."@"..evilJid.host) or + (evilJid.host and evilJid.resource and item.value == evilJid.host.."/"..evilJid.resource) or + (evilJid.host and item.value == evilJid.host) then + apply = true; + block = (item.action == "deny"); + elseif item.type == "group" then + local roster = load_roster(session.username, session.host); + local roster_entry = roster[jid_join(evilJid.node, evilJid.host)]; + if roster_entry then + local groups = roster_entry.groups; + for group in pairs(groups) do + if group == item.value then + apply = true; + block = (item.action == "deny"); + break; + end + end + end + elseif item.type == "subscription" then -- we need a valid bare evil jid + local roster = load_roster(session.username, session.host); + local roster_entry = roster[jid_join(evilJid.node, evilJid.host)]; + if (not(roster_entry) and item.value == "none") + or (roster_entry and roster_entry.subscription == item.value) then + apply = true; + block = (item.action == "deny"); + end + elseif item.type == nil then + apply = true; + block = (item.action == "deny"); + end + end + if apply then + if block then + -- drop and not bounce groupchat messages, otherwise users will get kicked + if stanza.attr.type == "groupchat" then + return true; + end + module:log("debug", "stanza blocked: %s, to: %s, from: %s", tostring(stanza.name), tostring(to), tostring(from)); + if stanza.name == "message" then + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + elseif stanza.name == "iq" and (stanza.attr.type == "get" or stanza.attr.type == "set") then + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + end + return true; -- stanza blocked ! + else + --module:log("debug", "stanza explicitly allowed!") + return; + end + end + end +end + +function preCheckIncoming(e) + local session; + if e.stanza.attr.to ~= nil then + local node, host, resource = jid_split(e.stanza.attr.to); + if node == nil or host == nil then + return; + end + if resource == nil then + local prio = 0; + if bare_sessions[node.."@"..host] ~= nil then + for resource, session_ in pairs(bare_sessions[node.."@"..host].sessions) do + if session_.priority ~= nil and session_.priority > prio then + session = session_; + prio = session_.priority; + end + end + end + else + session = full_sessions[node.."@"..host.."/"..resource]; + end + if session ~= nil then + return checkIfNeedToBeBlocked(e, session); + else + --module:log("debug", "preCheckIncoming: Couldn't get session for jid: %s@%s/%s", tostring(node), tostring(host), tostring(resource)); + end + end +end + +function preCheckOutgoing(e) + local session = e.origin; + if e.stanza.attr.from == nil then + e.stanza.attr.from = session.username .. "@" .. session.host; + if session.resource ~= nil then + e.stanza.attr.from = e.stanza.attr.from .. "/" .. session.resource; + end + end + if session.username then -- FIXME do properly + return checkIfNeedToBeBlocked(e, session); + end +end + +module:hook("pre-message/full", preCheckOutgoing, 500); +module:hook("pre-message/bare", preCheckOutgoing, 500); +module:hook("pre-message/host", preCheckOutgoing, 500); +module:hook("pre-iq/full", preCheckOutgoing, 500); +module:hook("pre-iq/bare", preCheckOutgoing, 500); +module:hook("pre-iq/host", preCheckOutgoing, 500); +module:hook("pre-presence/full", preCheckOutgoing, 500); +module:hook("pre-presence/bare", preCheckOutgoing, 500); +module:hook("pre-presence/host", preCheckOutgoing, 500); + +module:hook("message/full", preCheckIncoming, 500); +module:hook("message/bare", preCheckIncoming, 500); +module:hook("message/host", preCheckIncoming, 500); +module:hook("iq/full", preCheckIncoming, 500); +module:hook("iq/bare", preCheckIncoming, 500); +module:hook("iq/host", preCheckIncoming, 500); +module:hook("presence/full", preCheckIncoming, 500); +module:hook("presence/bare", preCheckIncoming, 500); +module:hook("presence/host", preCheckIncoming, 500); diff --git a/plugins/mod_private.lua b/plugins/mod_private.lua new file mode 100644 index 00000000..365a997c --- /dev/null +++ b/plugins/mod_private.lua @@ -0,0 +1,52 @@ +-- 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. +-- + + +local st = require "util.stanza" + +local private_storage = module:open_store(); + +module:add_feature("jabber:iq:private"); + +module:hook("iq/self/jabber:iq:private:query", function(event) + local origin, stanza = event.origin, event.stanza; + local type = stanza.attr.type; + local query = stanza.tags[1]; + if #query.tags == 1 then + local tag = query.tags[1]; + local key = tag.name..":"..tag.attr.xmlns; + local data, err = private_storage:get(origin.username); + if err then + origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + return true; + end + if stanza.attr.type == "get" then + if data and data[key] then + origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:private"}):add_child(st.deserialize(data[key]))); + else + origin.send(st.reply(stanza):add_child(stanza.tags[1])); + end + else -- set + if not data then data = {}; end; + if #tag == 0 then + data[key] = nil; + else + data[key] = st.preserialize(tag); + end + -- TODO delete datastore if empty + if private_storage:set(origin.username, data) then + origin.send(st.reply(stanza)); + else + origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + end + end + else + origin.send(st.error_reply(stanza, "modify", "bad-format")); + end + return true; +end); diff --git a/plugins/mod_proxy65.lua b/plugins/mod_proxy65.lua new file mode 100644 index 00000000..1fa42bd8 --- /dev/null +++ b/plugins/mod_proxy65.lua @@ -0,0 +1,192 @@ +-- Prosody IM +-- Copyright (C) 2008-2011 Matthew Wild +-- Copyright (C) 2008-2011 Waqas Hussain +-- Copyright (C) 2009 Thilo Cestonaro +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +module:set_global(); + +local jid_compare, jid_prep = require "util.jid".compare, require "util.jid".prep; +local st = require "util.stanza"; +local sha1 = require "util.hashes".sha1; +local b64 = require "util.encodings".base64.encode; +local server = require "net.server"; +local portmanager = require "core.portmanager"; + +local sessions, transfers = module:shared("sessions", "transfers"); +local max_buffer_size = 4096; + +local listener = {}; + +function listener.onincoming(conn, data) + local session = sessions[conn] or {}; + + local transfer = transfers[session.sha]; + if transfer and transfer.activated then -- copy data between initiator and target + local initiator, target = transfer.initiator, transfer.target; + (conn == initiator and target or initiator):write(data); + return; + end -- FIXME server.link should be doing this? + + if not session.greeting_done then + local nmethods = data:byte(2) or 0; + if data:byte(1) == 0x05 and nmethods > 0 and #data == 2 + nmethods then -- check if we have all the data + if data:find("%z") then -- 0x00 = 'No authentication' is supported + session.greeting_done = true; + sessions[conn] = session; + conn:write("\5\0"); -- send (SOCKS version 5, No authentication) + module:log("debug", "SOCKS5 greeting complete"); + return; + end + end -- else error, unexpected input + conn:write("\5\255"); -- send (SOCKS version 5, no acceptable method) + conn:close(); + module:log("debug", "Invalid SOCKS5 greeting recieved: '%s'", b64(data)); + else -- connection request + --local head = string.char( 0x05, 0x01, 0x00, 0x03, 40 ); -- ( VER=5=SOCKS5, CMD=1=CONNECT, RSV=0=RESERVED, ATYP=3=DOMAIMNAME, SHA-1 size ) + if #data == 47 and data:sub(1,5) == "\5\1\0\3\40" and data:sub(-2) == "\0\0" then + local sha = data:sub(6, 45); + conn:pause(); + conn:write("\5\0\0\3\40" .. sha .. "\0\0"); -- VER, REP, RSV, ATYP, BND.ADDR (sha), BND.PORT (2 Byte) + if not transfers[sha] then + transfers[sha] = {}; + transfers[sha].target = conn; + session.sha = sha; + module:log("debug", "SOCKS5 target connected for session %s", sha); + else -- transfers[sha].target ~= nil + transfers[sha].initiator = conn; + session.sha = sha; + module:log("debug", "SOCKS5 initiator connected for session %s", sha); + server.link(conn, transfers[sha].target, max_buffer_size); + server.link(transfers[sha].target, conn, max_buffer_size); + end + else -- error, unexpected input + conn:write("\5\1\0\3\0\0\0"); -- VER, REP, RSV, ATYP, BND.ADDR (sha), BND.PORT (2 Byte) + conn:close(); + module:log("debug", "Invalid SOCKS5 negotiation recieved: '%s'", b64(data)); + end + end +end + +function listener.ondisconnect(conn, err) + local session = sessions[conn]; + if session then + if transfers[session.sha] then + local initiator, target = transfers[session.sha].initiator, transfers[session.sha].target; + if initiator == conn and target ~= nil then + target:close(); + elseif target == conn and initiator ~= nil then + initiator:close(); + end + transfers[session.sha] = nil; + end + -- Clean up any session-related stuff here + sessions[conn] = nil; + end +end + +function module.add_host(module) + local host, name = module:get_host(), module:get_option_string("name", "SOCKS5 Bytestreams Service"); + + local proxy_address = module:get_option("proxy65_address", host); + local proxy_port = next(portmanager.get_active_services():search("proxy65", nil)[1] or {}); + local proxy_acl = module:get_option("proxy65_acl"); + + -- COMPAT w/pre-0.9 where proxy65_port was specified in the components section of the config + local legacy_config = module:get_option_number("proxy65_port"); + if legacy_config then + module:log("warn", "proxy65_port is deprecated, please put proxy65_ports = { %d } into the global section instead", legacy_config); + end + + module:add_identity("proxy", "bytestreams", name); + module:add_feature("http://jabber.org/protocol/bytestreams"); + + module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function(event) + local origin, stanza = event.origin, event.stanza; + if not stanza.tags[1].attr.node then + origin.send(st.reply(stanza):query("http://jabber.org/protocol/disco#info") + :tag("identity", {category='proxy', type='bytestreams', name=name}):up() + :tag("feature", {var="http://jabber.org/protocol/bytestreams"}) ); + return true; + end + end, -1); + + module:hook("iq-get/host/http://jabber.org/protocol/disco#items:query", function(event) + local origin, stanza = event.origin, event.stanza; + if not stanza.tags[1].attr.node then + origin.send(st.reply(stanza):query("http://jabber.org/protocol/disco#items")); + return true; + end + end, -1); + + module:hook("iq-get/host/http://jabber.org/protocol/bytestreams:query", function(event) + local origin, stanza = event.origin, event.stanza; + + -- check ACL + while proxy_acl and #proxy_acl > 0 do -- using 'while' instead of 'if' so we can break out of it + local jid = stanza.attr.from; + local allow; + for _, acl in ipairs(proxy_acl) do + 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)); + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + + local sid = stanza.tags[1].attr.sid; + origin.send(st.reply(stanza):tag("query", {xmlns="http://jabber.org/protocol/bytestreams", sid=sid}) + :tag("streamhost", {jid=host, host=proxy_address, port=proxy_port})); + return true; + end); + + module:hook("iq-set/host/http://jabber.org/protocol/bytestreams:query", function(event) + local origin, stanza = event.origin, event.stanza; + + local query = stanza.tags[1]; + local sid = query.attr.sid; + local from = stanza.attr.from; + local to = query:get_child_text("activate"); + local prepped_to = jid_prep(to); + + local info = "sid: "..tostring(sid)..", initiator: "..tostring(from)..", target: "..tostring(prepped_to or to); + if prepped_to and sid then + local sha = sha1(sid .. from .. prepped_to, true); + if not transfers[sha] then + module:log("debug", "Activation request has unknown session id; activation failed (%s)", info); + origin.send(st.error_reply(stanza, "modify", "item-not-found")); + elseif not transfers[sha].initiator then + module:log("debug", "The sender was not connected to the proxy; activation failed (%s)", info); + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "The sender (you) is not connected to the proxy")); + --elseif not transfers[sha].target then -- can't happen, as target is set when a transfer object is created + -- module:log("debug", "The recipient was not connected to the proxy; activation failed (%s)", info); + -- origin.send(st.error_reply(stanza, "cancel", "not-allowed", "The recipient is not connected to the proxy")); + else -- if transfers[sha].initiator ~= nil and transfers[sha].target ~= nil then + module:log("debug", "Transfer activated (%s)", info); + transfers[sha].activated = true; + transfers[sha].target:resume(); + transfers[sha].initiator:resume(); + origin.send(st.reply(stanza)); + end + elseif to and sid then + module:log("debug", "Malformed activation jid; activation failed (%s)", info); + origin.send(st.error_reply(stanza, "modify", "jid-malformed")); + else + module:log("debug", "Bad request; activation failed (%s)", info); + origin.send(st.error_reply(stanza, "modify", "bad-request")); + end + return true; + end); +end + +module:provides("net", { + default_port = 5000; + listener = listener; + multiplex = { + pattern = "^\5"; + }; +}); diff --git a/plugins/mod_pubsub.lua b/plugins/mod_pubsub.lua new file mode 100644 index 00000000..22969ab5 --- /dev/null +++ b/plugins/mod_pubsub.lua @@ -0,0 +1,466 @@ +local pubsub = require "util.pubsub"; +local st = require "util.stanza"; +local jid_bare = require "util.jid".bare; +local uuid_generate = require "util.uuid".generate; +local usermanager = require "core.usermanager"; + +local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; +local xmlns_pubsub_errors = "http://jabber.org/protocol/pubsub#errors"; +local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event"; +local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner"; + +local autocreate_on_publish = module:get_option_boolean("autocreate_on_publish", false); +local autocreate_on_subscribe = module:get_option_boolean("autocreate_on_subscribe", false); +local pubsub_disco_name = module:get_option("name"); +if type(pubsub_disco_name) ~= "string" then pubsub_disco_name = "Prosody PubSub Service"; end + +local service; + +local handlers = {}; + +function handle_pubsub_iq(event) + local origin, stanza = event.origin, event.stanza; + local pubsub = stanza.tags[1]; + local action = pubsub.tags[1]; + if not action then + return origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + local handler = handlers[stanza.attr.type.."_"..action.name]; + if handler then + handler(origin, stanza, action); + return true; + end +end + +local pubsub_errors = { + ["conflict"] = { "cancel", "conflict" }; + ["invalid-jid"] = { "modify", "bad-request", nil, "invalid-jid" }; + ["jid-required"] = { "modify", "bad-request", nil, "jid-required" }; + ["nodeid-required"] = { "modify", "bad-request", nil, "nodeid-required" }; + ["item-not-found"] = { "cancel", "item-not-found" }; + ["not-subscribed"] = { "modify", "unexpected-request", nil, "not-subscribed" }; + ["forbidden"] = { "cancel", "forbidden" }; +}; +function pubsub_error_reply(stanza, error) + local e = pubsub_errors[error]; + local reply = st.error_reply(stanza, unpack(e, 1, 3)); + if e[4] then + reply:tag(e[4], { xmlns = xmlns_pubsub_errors }):up(); + end + return reply; +end + +function handlers.get_items(origin, stanza, items) + local node = items.attr.node; + local item = items:get_child("item"); + local id = item and item.attr.id; + + if not node then + return origin.send(pubsub_error_reply(stanza, "nodeid-required")); + end + local ok, results = service:get_items(node, stanza.attr.from, id); + if not ok then + return origin.send(pubsub_error_reply(stanza, results)); + end + + local data = st.stanza("items", { node = node }); + for _, entry in pairs(results) do + data:add_child(entry); + 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 + return origin.send(reply); +end + +function handlers.get_subscriptions(origin, stanza, subscriptions) + local node = subscriptions.attr.node; + if not node then + return origin.send(pubsub_error_reply(stanza, "nodeid-required")); + end + local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from); + if not ok then + return origin.send(pubsub_error_reply(stanza, ret)); + end + local reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("subscriptions"); + for _, sub in ipairs(ret) do + reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up(); + end + return origin.send(reply); +end + +function handlers.set_create(origin, stanza, create) + local node = create.attr.node; + local ok, ret, reply; + if node then + ok, ret = service:create(node, stanza.attr.from); + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + else + repeat + node = uuid_generate(); + ok, ret = service:create(node, stanza.attr.from); + until ok or ret ~= "conflict"; + if ok then + reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("create", { node = node }); + else + reply = pubsub_error_reply(stanza, ret); + end + end + return origin.send(reply); +end + +function handlers.set_delete(origin, stanza, delete) + local node = delete.attr.node; + + local reply, notifier; + if not node then + return origin.send(pubsub_error_reply(stanza, "nodeid-required")); + end + local ok, ret = service:delete(node, stanza.attr.from); + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function handlers.set_subscribe(origin, stanza, subscribe) + local node, jid = subscribe.attr.node, subscribe.attr.jid; + if not (node and jid) then + return origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid")); + end + --[[ + local options_tag, options = stanza.tags[1]:get_child("options"), nil; + if options_tag then + options = options_form:data(options_tag.tags[1]); + end + --]] + local options_tag, options; -- FIXME + local ok, ret = service:add_subscription(node, stanza.attr.from, jid, options); + local reply; + if ok then + reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("subscription", { + node = node, + jid = jid, + subscription = "subscribed" + }):up(); + if options_tag then + reply:add_child(options_tag); + end + else + reply = pubsub_error_reply(stanza, ret); + end + origin.send(reply); +end + +function handlers.set_unsubscribe(origin, stanza, unsubscribe) + local node, jid = unsubscribe.attr.node, unsubscribe.attr.jid; + if not (node and jid) then + return origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid")); + end + local ok, ret = service:remove_subscription(node, stanza.attr.from, jid); + local reply; + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function handlers.set_publish(origin, stanza, publish) + local node = publish.attr.node; + if not node then + return origin.send(pubsub_error_reply(stanza, "nodeid-required")); + end + local item = publish:get_child("item"); + local id = (item and item.attr.id); + if not id then + id = uuid_generate(); + if item then + item.attr.id = id; + end + end + local ok, ret = service:publish(node, stanza.attr.from, id, item); + local reply; + if ok then + reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("publish", { node = node }) + :tag("item", { id = id }); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function handlers.set_retract(origin, stanza, retract) + local node, notify = retract.attr.node, retract.attr.notify; + notify = (notify == "1") or (notify == "true"); + local item = retract:get_child("item"); + local id = item and item.attr.id + if not (node and id) then + return origin.send(pubsub_error_reply(stanza, node and "item-not-found" or "nodeid-required")); + end + local reply, notifier; + if notify then + notifier = st.stanza("retract", { id = id }); + end + local ok, ret = service:retract(node, stanza.attr.from, id, notifier); + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function handlers.set_purge(origin, stanza, purge) + local node, notify = purge.attr.node, purge.attr.notify; + notify = (notify == "1") or (notify == "true"); + local reply; + if not node then + return origin.send(pubsub_error_reply(stanza, "nodeid-required")); + end + local ok, ret = service:purge(node, stanza.attr.from, notify); + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function simple_broadcast(kind, node, jids, item) + if item then + item = st.clone(item); + item.attr.xmlns = nil; -- Clear the pubsub namespace + end + local message = st.message({ from = module.host, type = "headline" }) + :tag("event", { xmlns = xmlns_pubsub_event }) + :tag(kind, { node = node }) + :add_child(item); + for jid in pairs(jids) do + module:log("debug", "Sending notification to %s", jid); + message.attr.to = jid; + module:send(message); + end +end + +module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq); +module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq); + +local disco_info; + +local feature_map = { + create = { "create-nodes", "instant-nodes", "item-ids" }; + retract = { "delete-items", "retract-items" }; + purge = { "purge-nodes" }; + publish = { "publish", autocreate_on_publish and "auto-create" }; + delete = { "delete-nodes" }; + get_items = { "retrieve-items" }; + add_subscription = { "subscribe" }; + get_subscriptions = { "retrieve-subscriptions" }; +}; + +local function add_disco_features_from_service(disco, service) + for method, features in pairs(feature_map) do + if service[method] then + for _, feature in ipairs(features) do + if feature then + disco:tag("feature", { var = xmlns_pubsub.."#"..feature }):up(); + end + end + end + end + for affiliation in pairs(service.config.capabilities) do + if affiliation ~= "none" and affiliation ~= "owner" then + disco:tag("feature", { var = xmlns_pubsub.."#"..affiliation.."-affiliation" }):up(); + end + end +end + +local function build_disco_info(service) + local disco_info = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info" }) + :tag("identity", { category = "pubsub", type = "service", name = pubsub_disco_name }):up() + :tag("feature", { var = "http://jabber.org/protocol/pubsub" }):up(); + add_disco_features_from_service(disco_info, service); + return disco_info; +end + +module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function (event) + local origin, stanza = event.origin, event.stanza; + local node = stanza.tags[1].attr.node; + if not node then + return origin.send(st.reply(stanza):add_child(disco_info)); + else + local ok, ret = service:get_nodes(stanza.attr.from); + if ok and not ret[node] then + ok, ret = false, "item-not-found"; + end + if not ok then + return origin.send(pubsub_error_reply(stanza, ret)); + end + local reply = st.reply(stanza) + :tag("query", { xmlns = "http://jabber.org/protocol/disco#info", node = node }) + :tag("identity", { category = "pubsub", type = "leaf" }); + return origin.send(reply); + end +end); + +local function handle_disco_items_on_node(event) + local stanza, origin = event.stanza, event.origin; + local query = stanza.tags[1]; + local node = query.attr.node; + local ok, ret = service:get_items(node, stanza.attr.from); + if not ok then + return origin.send(pubsub_error_reply(stanza, ret)); + end + + local reply = st.reply(stanza) + :tag("query", { xmlns = "http://jabber.org/protocol/disco#items", node = node }); + + for id, item in pairs(ret) do + reply:tag("item", { jid = module.host, name = id }):up(); + end + + return origin.send(reply); +end + + +module:hook("iq-get/host/http://jabber.org/protocol/disco#items:query", function (event) + if event.stanza.tags[1].attr.node then + return handle_disco_items_on_node(event); + end + local ok, ret = service:get_nodes(event.stanza.attr.from); + if not ok then + event.origin.send(pubsub_error_reply(event.stanza, ret)); + else + local reply = st.reply(event.stanza) + :tag("query", { xmlns = "http://jabber.org/protocol/disco#items" }); + for node, node_obj in pairs(ret) do + reply:tag("item", { jid = module.host, node = node, name = node_obj.config.name }):up(); + end + event.origin.send(reply); + end + return true; +end); + +local admin_aff = module:get_option_string("default_admin_affiliation", "owner"); +local function get_affiliation(jid) + local bare_jid = jid_bare(jid); + if bare_jid == module.host or usermanager.is_admin(bare_jid, module.host) then + return admin_aff; + end +end + +function set_service(new_service) + service = new_service; + module.environment.service = service; + disco_info = build_disco_info(service); +end + +function module.save() + return { service = service }; +end + +function module.restore(data) + set_service(data.service); +end + +set_service(pubsub.new({ + capabilities = { + none = { + create = false; + publish = false; + retract = false; + get_nodes = true; + + subscribe = true; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = true; + + subscribe_other = false; + unsubscribe_other = false; + get_subscription_other = false; + get_subscriptions_other = false; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = false; + }; + publisher = { + create = false; + publish = true; + retract = true; + get_nodes = true; + + subscribe = true; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = true; + + subscribe_other = false; + unsubscribe_other = false; + get_subscription_other = false; + get_subscriptions_other = false; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = false; + }; + owner = { + create = true; + publish = true; + retract = true; + delete = true; + get_nodes = true; + + subscribe = true; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = true; + + + subscribe_other = true; + unsubscribe_other = true; + get_subscription_other = true; + get_subscriptions_other = true; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = true; + }; + }; + + autocreate_on_publish = autocreate_on_publish; + autocreate_on_subscribe = autocreate_on_subscribe; + + broadcaster = simple_broadcast; + get_affiliation = get_affiliation; + + normalize_jid = jid_bare; +})); diff --git a/plugins/mod_register.lua b/plugins/mod_register.lua index b1bf4cc0..141a4997 100644 --- a/plugins/mod_register.lua +++ b/plugins/mod_register.lua @@ -1,84 +1,257 @@ +-- 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. +-- + local st = require "util.stanza"; -local send = require "core.sessionmanager".send_to_session; +local dataform_new = require "util.dataforms".new; local usermanager_user_exists = require "core.usermanager".user_exists; local usermanager_create_user = require "core.usermanager".create_user; +local usermanager_set_password = require "core.usermanager".set_password; +local usermanager_delete_user = require "core.usermanager".delete_user; +local os_time = os.time; +local nodeprep = require "util.encodings".stringprep.nodeprep; +local jid_bare = require "util.jid".bare; -add_iq_handler("c2s", "jabber:iq:register", function (session, stanza) - if stanza.tags[1].name == "query" then - local query = stanza.tags[1]; - if stanza.attr.type == "get" then - local reply = st.reply(stanza); - reply:tag("query", {xmlns = "jabber:iq:register"}) - :tag("registered"):up() - :tag("username"):text(session.username):up() - :tag("password"):up(); - send(session, reply); - elseif stanza.attr.type == "set" then - if query.tags[1] and query.tags[1].name == "remove" then - -- TODO delete user auth data, send iq response, kick all user resources with a <not-authorized/>, delete all user data - send(session, st.error_reply(stanza, "cancel", "not-allowed")); - else - local username = query:child_with_name("username"); - local password = query:child_with_name("password"); - if username and password then - -- FIXME shouldn't use table.concat - username = table.concat(username); - password = table.concat(password); - if username == session.username then - if usermanager_create_user(username, password, session.host) then -- password change -- TODO is this the right way? - send(session, st.reply(stanza)); - else - -- TODO internal error, unable to write file, file may be locked, etc - end +local compat = module:get_option_boolean("registration_compat", true); +local allow_registration = module:get_option_boolean("allow_registration", false); +local additional_fields = module:get_option("additional_registration_fields", {}); + +local account_details = module:open_store("account_details"); + +local field_map = { + username = { name = "username", type = "text-single", label = "Username", required = true }; + password = { name = "password", type = "text-private", label = "Password", required = true }; + nick = { name = "nick", type = "text-single", label = "Nickname" }; + name = { name = "name", type = "text-single", label = "Full Name" }; + first = { name = "first", type = "text-single", label = "Given Name" }; + last = { name = "last", type = "text-single", label = "Family Name" }; + email = { name = "email", type = "text-single", label = "Email" }; + address = { name = "address", type = "text-single", label = "Street" }; + city = { name = "city", type = "text-single", label = "City" }; + state = { name = "state", type = "text-single", label = "State" }; + zip = { name = "zip", type = "text-single", label = "Postal code" }; + phone = { name = "phone", type = "text-single", label = "Telephone number" }; + url = { name = "url", type = "text-single", label = "Webpage" }; + date = { name = "date", type = "text-single", label = "Birth date" }; +}; + +local registration_form = dataform_new{ + title = "Creating a new account"; + instructions = "Choose a username and password for use with this service."; + + field_map.username; + field_map.password; +}; + +local registration_query = st.stanza("query", {xmlns = "jabber:iq:register"}) + :tag("instructions"):text("Choose a username and password for use with this service."):up() + :tag("username"):up() + :tag("password"):up(); + +for _, field in ipairs(additional_fields) do + if type(field) == "table" then + registration_form[#registration_form + 1] = field; + else + if field:match("%+$") then + field = field:sub(1, #field - 1); + field_map[field].required = true; + end + + registration_form[#registration_form + 1] = field_map[field]; + registration_query:tag(field):up(); + end +end +registration_query:add_child(registration_form:form()); + +module:add_feature("jabber:iq:register"); + +local register_stream_feature = st.stanza("register", {xmlns="http://jabber.org/features/iq-register"}):up(); +module:hook("stream-features", function(event) + local session, features = event.origin, event.features; + + -- Advertise registration to unauthorized clients only. + if not(allow_registration) or session.type ~= "c2s_unauthed" then + return + end + + features:add_child(register_stream_feature); +end); + +local function handle_registration_stanza(event) + local session, stanza = event.origin, event.stanza; + + local query = stanza.tags[1]; + if stanza.attr.type == "get" then + local reply = st.reply(stanza); + reply:tag("query", {xmlns = "jabber:iq:register"}) + :tag("registered"):up() + :tag("username"):text(session.username):up() + :tag("password"):up(); + session.send(reply); + else -- stanza.attr.type == "set" + if query.tags[1] and query.tags[1].name == "remove" then + local username, host = session.username, session.host; + + local old_session_close = session.close; + session.close = function(session, ...) + session.send(st.reply(stanza)); + return old_session_close(session, ...); + end + + local ok, err = usermanager_delete_user(username, host); + + if not ok then + module:log("debug", "Removing user account %s@%s failed: %s", username, host, err); + session.close = old_session_close; + session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); + return true; + end + + module: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("username"):get_text()); + local password = query:get_child("password"):get_text(); + if username and password then + if username == session.username then + if usermanager_set_password(username, password, session.host) then + session.send(st.reply(stanza)); else - send(session, st.error_reply(stanza, "modify", "bad-request")); + -- TODO unable to write file, file may be locked, etc, what's the correct error? + session.send(st.error_reply(stanza, "wait", "internal-server-error")); end else - send(session, st.error_reply(stanza, "modify", "bad-request")); + session.send(st.error_reply(stanza, "modify", "bad-request")); end + else + session.send(st.error_reply(stanza, "modify", "bad-request")); end end + end + return true; +end + +module:hook("iq/self/jabber:iq:register:query", handle_registration_stanza); +if compat then + module:hook("iq/host/jabber:iq:register:query", function (event) + local session, stanza = event.origin, event.stanza; + if session.type == "c2s" and jid_bare(stanza.attr.to) == session.host then + return handle_registration_stanza(event); + end + end); +end + +local function parse_response(query) + local form = query:get_child("x", "jabber:x:data"); + if form then + return registration_form:data(form); else - send(session, st.error_reply(stanza, "cancel", "service-unavailable")); - end; -end); + local data = {}; + local errors = {}; + for _, field in ipairs(registration_form) do + local name, required = field.name, field.required; + if field_map[name] then + data[name] = query:get_child_text(name); + if (not data[name] or #data[name] == 0) and required then + errors[name] = "Required value missing"; + end + end + end + if next(errors) then + return data, errors; + end + return data; + end +end + +local recent_ips = {}; +local min_seconds_between_registrations = module:get_option("min_seconds_between_registrations"); +local whitelist_only = module:get_option("whitelist_registration_only"); +local whitelisted_ips = module:get_option("registration_whitelist") or { "127.0.0.1" }; +local blacklisted_ips = module:get_option("registration_blacklist") or {}; + +for _, ip in ipairs(whitelisted_ips) do whitelisted_ips[ip] = true; end +for _, ip in ipairs(blacklisted_ips) do blacklisted_ips[ip] = true; end -add_iq_handler("c2s_unauthed", "jabber:iq:register", function (session, stanza) - if stanza.tags[1].name == "query" then +module:hook("stanza/iq/jabber:iq:register:query", function(event) + local session, stanza = event.origin, event.stanza; + + if not(allow_registration) or session.type ~= "c2s_unauthed" then + session.send(st.error_reply(stanza, "cancel", "service-unavailable")); + else local query = stanza.tags[1]; if stanza.attr.type == "get" then local reply = st.reply(stanza); - reply:tag("query", {xmlns = "jabber:iq:register"}) - :tag("instructions"):text("Choose a username and password for use with this service."):up() - :tag("username"):up() - :tag("password"):up(); - send(session, reply); + reply:add_child(registration_query); + session.send(reply); elseif stanza.attr.type == "set" then if query.tags[1] and query.tags[1].name == "remove" then - send(session, st.error_reply(stanza, "auth", "registration-required")); + session.send(st.error_reply(stanza, "auth", "registration-required")); else - local username = query:child_with_name("username"); - local password = query:child_with_name("password"); - if username and password then - -- FIXME shouldn't use table.concat - username = table.concat(username); - password = table.concat(password); - if usermanager_user_exists(username, session.host) then - send(session, st.error_reply(stanza, "cancel", "conflict")); + local data, errors = parse_response(query); + if errors then + session.send(st.error_reply(stanza, "modify", "not-acceptable")); + else + -- Check that the user is not blacklisted or registering too often + if not session.ip then + module:log("debug", "User's IP not known; can't apply blacklist/whitelist"); + elseif blacklisted_ips[session.ip] or (whitelist_only and not whitelisted_ips[session.ip]) then + session.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not allowed to register an account.")); + return true; + elseif min_seconds_between_registrations and not whitelisted_ips[session.ip] then + if not recent_ips[session.ip] then + recent_ips[session.ip] = { time = os_time(), count = 1 }; + else + local ip = recent_ips[session.ip]; + ip.count = ip.count + 1; + + if os_time() - ip.time < min_seconds_between_registrations then + ip.time = os_time(); + session.send(st.error_reply(stanza, "wait", "not-acceptable")); + return true; + end + ip.time = os_time(); + end + end + local username, password = nodeprep(data.username), data.password; + data.username, data.password = nil, nil; + local host = module.host; + if not username or username == "" then + session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is invalid.")); + return true; + end + local user = { username = username , host = host, allowed = true } + module:fire_event("user-registering", user); + if not user.allowed then + session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is forbidden.")); + elseif usermanager_user_exists(username, host) then + session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists.")); else - if usermanager_create_user(username, password, session.host) then - send(session, st.reply(stanza)); -- user created! + -- 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 + if next(data) and not account_details:set(username, data) then + usermanager_delete_user(username, host); + session.send(error_reply); + return true; + end + session.send(st.reply(stanza)); -- user created! + module:log("info", "User account created: %s@%s", username, host); + module:fire_event("user-registered", { + username = username, host = host, source = "mod_register", + session = session }); else - -- TODO internal error, unable to write file, file may be locked, etc + session.send(error_reply); end end - else - send(session, st.error_reply(stanza, "modify", "not-acceptable")); end end end - else - send(session, st.error_reply(stanza, "cancel", "service-unavailable")); - end; + end + return true; end); diff --git a/plugins/mod_roster.lua b/plugins/mod_roster.lua index 8b4a3a0a..d530bb45 100644 --- a/plugins/mod_roster.lua +++ b/plugins/mod_roster.lua @@ -1,24 +1,156 @@ +-- 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. +-- + local st = require "util.stanza" -local send = require "core.sessionmanager".send_to_session - -add_iq_handler("c2s", "jabber:iq:roster", - function (session, stanza) - if stanza.attr.type == "get" then - session.roster = session.roster or rostermanager.getroster(session.username, session.host); - if session.roster == false then - send(session, st.reply(stanza) - :tag("error", { type = "wait" }) - :tag("internal-server-error", { xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"})); - return true; - else session.roster = session.roster or {}; + +local jid_split = require "util.jid".split; +local jid_prep = require "util.jid".prep; +local t_concat = table.concat; +local tonumber = tonumber; +local pairs, ipairs = pairs, ipairs; + +local rm_load_roster = require "core.rostermanager".load_roster; +local rm_remove_from_roster = require "core.rostermanager".remove_from_roster; +local rm_add_to_roster = require "core.rostermanager".add_to_roster; +local rm_roster_push = require "core.rostermanager".roster_push; +local core_post_stanza = prosody.core_post_stanza; + +module:add_feature("jabber:iq:roster"); + +local rosterver_stream_feature = st.stanza("ver", {xmlns="urn:xmpp:features:rosterver"}); +module:hook("stream-features", function(event) + local origin, features = event.origin, event.features; + if origin.username then + features:add_child(rosterver_stream_feature); + end +end); + +module:hook("iq/self/jabber:iq:roster:query", function(event) + local session, stanza = event.origin, event.stanza; + + if stanza.attr.type == "get" then + local roster = st.reply(stanza); + + local client_ver = tonumber(stanza.tags[1].attr.ver); + local server_ver = tonumber(session.roster[false].version or 1); + + if not (client_ver and server_ver) or client_ver ~= server_ver then + roster:query("jabber:iq:roster"); + -- Client does not support versioning, or has stale roster + for jid, item in pairs(session.roster) do + if jid ~= "pending" and jid then + roster:tag("item", { + jid = jid, + subscription = item.subscription, + ask = item.ask, + name = item.name, + }); + for group in pairs(item.groups) do + roster:tag("group"):text(group):up(); + end + roster:up(); -- move out from item end - local roster = st.reply(stanza) - :query("jabber:iq:roster"); - for jid in pairs(session.roster) do - roster:tag("item", { jid = jid, subscription = "none" }):up(); + end + roster.tags[1].attr.ver = server_ver; + end + session.send(roster); + session.interested = true; -- resource is interested in roster updates + else -- stanza.attr.type == "set" + local query = stanza.tags[1]; + if #query.tags == 1 and query.tags[1].name == "item" + and query.tags[1].attr.xmlns == "jabber:iq:roster" and query.tags[1].attr.jid + -- Protection against overwriting roster.pending, until we move it + and query.tags[1].attr.jid ~= "pending" then + local item = query.tags[1]; + local from_node, from_host = jid_split(stanza.attr.from); + local jid = jid_prep(item.attr.jid); + local node, host, resource = jid_split(jid); + if not resource and host then + if jid ~= from_node.."@"..from_host then + if item.attr.subscription == "remove" then + local roster = session.roster; + local r_item = roster[jid]; + if r_item then + local to_bare = node and (node.."@"..host) or host; -- bare JID + if r_item.subscription == "both" or r_item.subscription == "from" or (roster.pending and roster.pending[jid]) then + core_post_stanza(session, st.presence({type="unsubscribed", from=session.full_jid, to=to_bare})); + end + if r_item.subscription == "both" or r_item.subscription == "to" or r_item.ask then + core_post_stanza(session, st.presence({type="unsubscribe", from=session.full_jid, to=to_bare})); + end + local success, err_type, err_cond, err_msg = rm_remove_from_roster(session, jid); + if success then + session.send(st.reply(stanza)); + rm_roster_push(from_node, from_host, jid); + else + session.send(st.error_reply(stanza, err_type, err_cond, err_msg)); + end + else + session.send(st.error_reply(stanza, "modify", "item-not-found")); + end + else + local r_item = {name = item.attr.name, groups = {}}; + if r_item.name == "" then r_item.name = nil; end + if session.roster[jid] then + r_item.subscription = session.roster[jid].subscription; + r_item.ask = session.roster[jid].ask; + else + r_item.subscription = "none"; + end + for _, child in ipairs(item) do + if child.name == "group" then + local text = t_concat(child); + if text and text ~= "" then + r_item.groups[text] = true; + end + end + end + local success, err_type, err_cond, err_msg = rm_add_to_roster(session, jid, r_item); + if success then + -- Ok, send success + session.send(st.reply(stanza)); + -- and push change to all resources + rm_roster_push(from_node, from_host, jid); + else + -- Adding to roster failed + session.send(st.error_reply(stanza, err_type, err_cond, err_msg)); + end + end + else + -- Trying to add self to roster + session.send(st.error_reply(stanza, "cancel", "not-allowed")); end - send(session, roster); - return true; + else + -- Invalid JID added to roster + session.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME what's the correct error? + end + else + -- Roster set didn't include a single item, or its name wasn't 'item' + session.send(st.error_reply(stanza, "modify", "bad-request")); + end + end + return true; +end); + +module:hook_global("user-deleted", function(event) + local username, host = event.username, event.host; + if host ~= module.host then return end + local bare = username .. "@" .. host; + local roster = rm_load_roster(username, host); + for jid, item in pairs(roster) do + if jid and jid ~= "pending" then + if item.subscription == "both" or item.subscription == "from" or (roster.pending and roster.pending[jid]) then + module:send(st.presence({type="unsubscribed", from=bare, to=jid})); + end + if item.subscription == "both" or item.subscription == "to" or item.ask then + module:send(st.presence({type="unsubscribe", from=bare, to=jid})); end - end);
\ No newline at end of file + end + end +end, 300); diff --git a/plugins/mod_s2s/mod_s2s.lua b/plugins/mod_s2s/mod_s2s.lua new file mode 100644 index 00000000..30ebb706 --- /dev/null +++ b/plugins/mod_s2s/mod_s2s.lua @@ -0,0 +1,677 @@ +-- 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:set_global(); + +local prosody = prosody; +local hosts = prosody.hosts; +local core_process_stanza = prosody.core_process_stanza; + +local tostring, type = tostring, type; +local t_insert = table.insert; +local xpcall, traceback = xpcall, debug.traceback; +local NULL = {}; + +local add_task = require "util.timer".add_task; +local st = require "util.stanza"; +local initialize_filters = require "util.filters".initialize; +local nameprep = require "util.encodings".stringprep.nameprep; +local new_xmpp_stream = require "util.xmppstream".new; +local s2s_new_incoming = require "core.s2smanager".new_incoming; +local s2s_new_outgoing = require "core.s2smanager".new_outgoing; +local s2s_destroy_session = require "core.s2smanager".destroy_session; +local uuid_gen = require "util.uuid".generate; +local cert_verify_identity = require "util.x509".verify_identity; +local fire_global_event = prosody.events.fire_event; + +local s2sout = module:require("s2sout"); + +local connect_timeout = module:get_option_number("s2s_timeout", 90); +local stream_close_timeout = module:get_option_number("s2s_close_timeout", 5); +local opt_keepalives = module:get_option_boolean("s2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true)); +local secure_auth = module:get_option_boolean("s2s_secure_auth", false); -- One day... +local secure_domains, insecure_domains = + module:get_option_set("s2s_secure_domains", {})._items, module:get_option_set("s2s_insecure_domains", {})._items; +local require_encryption = module:get_option_boolean("s2s_require_encryption", secure_auth); + +local sessions = module:shared("sessions"); + +local log = module._log; + +--- Handle stanzas to remote domains + +local bouncy_stanzas = { message = true, presence = true, iq = true }; +local function bounce_sendq(session, reason) + local sendq = session.sendq; + if not sendq then return; end + session.log("info", "sending error replies for "..#sendq.." queued stanzas because of failed outgoing connection to "..tostring(session.to_host)); + local dummy = { + type = "s2sin"; + send = function(s) + (session.log or log)("error", "Replying to to an s2s error reply, please report this! Traceback: %s", traceback()); + end; + dummy = true; + }; + 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"}) + :tag("remote-server-not-found", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up(); + if reason then + reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}) + :text("Server-to-server connection failed: "..reason):up(); + end + core_process_stanza(dummy, reply); + end + sendq[i] = nil; + end + session.sendq = nil; +end + +-- Handles stanzas to existing s2s sessions +function route_to_existing_session(event) + local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza; + if not hosts[from_host] then + log("warn", "Attempt to send stanza from %s - a host we don't serve", from_host); + return false; + end + if hosts[to_host] then + log("warn", "Attempt to route stanza to a remote %s - a host we do serve?!", from_host); + 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 + if host.sendq then t_insert(host.sendq, {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)}); + else host.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} }; 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; + else + (host.log or log)("debug", "going to send stanza to "..to_host.." from "..from_host); + -- 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", tostring(host.from_host), tostring(from_host)); + end + if host.sends2s(stanza) then + host.log("debug", "stanza sent over %s", host.type); + return true; + end + end + end +end + +-- Create a new outgoing session for a stanza +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); + + -- 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 + return true; +end + +function module.add_host(module) + if module:get_option_boolean("disallow_s2s", false) then + module:log("warn", "The 'disallow_s2s' config option is deprecated, please see http://prosody.im/doc/s2s#disabling"); + return nil, "This host has disallow_s2s set"; + end + module:hook("route/remote", route_to_existing_session, -1); + module:hook("route/remote", route_to_new_session, -10); + module:hook("s2s-authenticated", make_authenticated, -1); +end + +-- Stream is authorised, and ready for normal stanzas +function mark_connected(session) + local sendq, send = session.sendq, session.sends2s; + + local from, to = session.from_host, session.to_host; + + session.log("info", "%s s2s connection %s->%s complete", session.direction, from, to); + + local event_data = { session = session }; + if session.type == "s2sout" then + fire_global_event("s2sout-established", event_data); + hosts[from].events.fire_event("s2sout-established", event_data); + else + local host_session = hosts[to]; + session.send = function(stanza) + return host_session.events.fire_event("route/remote", { from_host = to, to_host = from, stanza = stanza }); + end; + + fire_global_event("s2sin-established", event_data); + hosts[to].events.fire_event("s2sin-established", event_data); + end + + if session.direction == "outgoing" then + if sendq then + session.log("debug", "sending %d queued stanzas across new outgoing connection to %s", #sendq, session.to_host); + for i, data in ipairs(sendq) do + send(data[1]); + sendq[i] = nil; + end + session.sendq = nil; + end + + session.ip_hosts = nil; + session.srv_hosts = nil; + end +end + +function make_authenticated(event) + local session, host = event.session, event.host; + if not session.secure then + if require_encryption or secure_auth or secure_domains[host] then + session:close({ + condition = "policy-violation", + text = "Encrypted server-to-server communication is required but was not " + ..((session.direction == "outgoing" and "offered") or "used") + }); + end + end + if hosts[host] then + session:close({ condition = "undefined-condition", text = "Attempt to authenticate as a host we serve" }); + end + if session.type == "s2sout_unauthed" then + 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 + 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); + + mark_connected(session); + + return true; +end + +--- Helper to check that a session peer's certificate is valid +local function check_cert_status(session) + local host = session.direction == "outgoing" and session.to_host or session.from_host + local conn = session.conn:socket() + local cert + if conn.getpeercertificate then + cert = conn:getpeercertificate() + end + + if cert then + 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 + -- Is there any interest in printing out all/the number of errors here? + if not chain_valid then + (session.log or log)("debug", "certificate chain validation result: invalid"); + for depth, t in ipairs(errors or NULL) do + (session.log or log)("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", ")) + end + session.cert_chain_status = "invalid"; + else + (session.log or log)("debug", "certificate chain validation result: valid"); + session.cert_chain_status = "valid"; + + -- We'll go ahead and verify the asserted identity if the + -- connecting server specified one. + if host then + if cert_verify_identity(host, "xmpp-server", cert) then + session.cert_identity_status = "valid" + else + session.cert_identity_status = "invalid" + end + end + end + end + return module:fire_event("s2s-check-certificate", { host = host, session = session, cert = cert }); +end + +--- XMPP stream event handlers + +local stream_callbacks = { default_ns = "jabber:server", handlestanza = core_process_stanza }; + +local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; + +function stream_callbacks.streamopened(session, attr) + local send = session.sends2s; + + session.version = tonumber(attr.version) or 0; + + -- TODO: Rename session.secure to session.encrypted + if session.secure == false then + session.secure = true; + + -- Check if TLS compression is used + local sock = session.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 + + if session.direction == "incoming" then + -- Send a reply stream header + + -- Validate to/from + local to, from = nameprep(attr.to), nameprep(attr.from); + 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; + end + if not from and attr.from then -- COMPAT: Some servers do not reliably set 'from' (especially on stream restarts) + session:close({ condition = "improper-addressing", text = "Invalid 'from' address" }); + return; + end + + -- Set session.[from/to]_host if they have not been set already and if + -- this session isn't already authenticated + if session.type == "s2sin_unauthed" and from and not session.from_host then + session.from_host = from; + elseif from ~= session.from_host then + session:close({ condition = "improper-addressing", text = "New stream 'from' attribute does not match original" }); + return; + end + if session.type == "s2sin_unauthed" and to and not session.to_host then + session.to_host = to; + elseif to ~= session.to_host then + session:close({ condition = "improper-addressing", text = "New stream 'to' attribute does not match original" }); + return; + end + + -- For convenience we'll put the sanitised values into these variables + to, from = session.to_host, session.from_host; + + session.streamid = uuid_gen(); + (session.log or log)("debug", "Incoming s2s received %s", st.stanza("stream:stream", attr):top_tag()); + if to then + if not hosts[to] then + -- Attempting to connect to a host we don't serve + session:close({ + condition = "host-unknown"; + text = "This host does not serve "..to + }); + return; + elseif not hosts[to].modules.s2s then + -- Attempting to connect to a host that disallows s2s + session:close({ + condition = "policy-violation"; + text = "Server-to-server communication is disabled for this host"; + }); + return; + end + end + + if hosts[from] then + session:close({ condition = "undefined-condition", text = "Attempt to connect from a host we serve" }); + return; + end + + if session.secure and not session.cert_chain_status then + if check_cert_status(session) == false then + return; + end + end + + session:open_stream(session.to_host, session.from_host) + if session.version >= 1.0 then + local features = st.stanza("stream:features"); + + if to then + hosts[to].events.fire_event("s2s-stream-features", { origin = session, features = features }); + else + (session.log or log)("warn", "No 'to' on stream header from %s means we can't offer any features", from or "unknown host"); + end + + log("debug", "Sending stream features: %s", tostring(features)); + send(features); + end + elseif session.direction == "outgoing" then + -- If we are just using the connection for verifying dialback keys, we won't try and auth it + if not attr.id then error("stream response did not give us a streamid!!!"); end + session.streamid = attr.id; + + if session.secure and not session.cert_chain_status then + if check_cert_status(session) == false then + return; + end + end + + -- Send unauthed buffer + -- (stanzas which are fine to send before dialback) + -- Note that this is *not* the stanza queue (which + -- we can only send if auth succeeds) :) + local send_buffer = session.send_buffer; + if send_buffer and #send_buffer > 0 then + log("debug", "Sending s2s send_buffer now..."); + for i, data in ipairs(send_buffer) do + session.sends2s(tostring(data)); + send_buffer[i] = nil; + end + end + session.send_buffer = nil; + + -- If server is pre-1.0, don't wait for features, just do dialback + if session.version < 1.0 then + if not session.dialback_verifying then + hosts[session.from_host].events.fire_event("s2sout-authenticate-legacy", { origin = session }); + else + mark_connected(session); + end + end + end + session.notopen = nil; +end + +function stream_callbacks.streamclosed(session) + (session.log or log)("debug", "Received </stream:stream>"); + session:close(false); +end + +function stream_callbacks.error(session, error, data) + if error == "no-stream" then + session:close("invalid-namespace"); + elseif error == "parse-error" then + session.log("debug", "Server-to-server XML parse error: %s", tostring(error)); + session:close("not-well-formed"); + elseif error == "stream-error" then + local condition, text = "undefined-condition"; + for child in data:children() do + if child.attr.xmlns == xmlns_xmpp_streams then + if child.name ~= "text" then + condition = child.name; + else + text = child:get_text(); + end + if condition ~= "undefined-condition" and text then + break; + end + end + end + text = condition .. (text and (" ("..text..")") or ""); + session.log("info", "Session closed by remote with error: %s", text); + session:close(nil, text); + end +end + +local function handleerr(err) log("error", "Traceback[s2s]: %s", traceback(tostring(err), 2)); end +function stream_callbacks.handlestanza(session, stanza) + if stanza.attr.xmlns == "jabber:client" then --COMPAT: Prosody pre-0.6.2 may send jabber:client + stanza.attr.xmlns = nil; + end + stanza = session.filter("stanzas/in", stanza); + if stanza then + return xpcall(function () return core_process_stanza(session, stanza) end, handleerr); + 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) + local log = session.log or log; + if session.conn then + if session.notopen then + if session.direction == "incoming" then + session:open_stream(session.to_host, session.from_host); + else + session:open_stream(session.from_host, session.to_host); + end + end + if reason then -- nil == no err, initiated by us, false == initiated by remote + if type(reason) == "string" then -- assume stream error + log("debug", "Disconnecting %s[%s], <stream:error> is: %s", session.host or "(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 "(unknown host)", session.type, tostring(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, tostring(reason)); + session.sends2s(reason); + end + end + end + + session.sends2s("</stream:stream>"); + function session.sends2s() return false; end + + local reason = remote_reason or (reason and (reason.text or reason.condition)) or reason; + session.log("info", "%s s2s stream %s->%s closed: %s", session.direction, session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed"); + + -- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote + local conn = session.conn; + if reason == nil and not session.notopen and session.type == "s2sin" then + add_task(stream_close_timeout, function () + if not session.destroyed then + session.log("warn", "Failed to receive a stream close response, closing connection anyway..."); + s2s_destroy_session(session, reason); + conn:close(); + end + end); + else + s2s_destroy_session(session, reason); + conn:close(); -- Close immediately, as this is an outgoing connection or is not authed + end + end +end + +function session_open_stream(session, from, to) + local attr = { + ["xmlns:stream"] = 'http://etherx.jabber.org/streams', + xmlns = 'jabber:server', + version = session.version and (session.version > 0 and "1.0" or nil), + ["xml:lang"] = 'en', + id = session.streamid, + from = from, to = to, + } + if not from or (hosts[from] and hosts[from].modules.dialback) then + attr["xmlns:db"] = 'jabber:server:dialback'; + end + + session.sends2s("<?xml version='1.0'?>"); + session.sends2s(st.stanza("stream:stream", attr):top_tag()); + return true; +end + +-- Session initialization logic shared by incoming and outgoing +local function initialize_session(session) + local stream = new_xmpp_stream(session, stream_callbacks); + session.stream = stream; + + session.notopen = true; + + function session.reset_stream() + session.notopen = true; + session.stream:reset(); + end + + session.open_stream = session_open_stream; + + local filter = session.filter; + function session.data(data) + data = filter("bytes/in", data); + if data then + local ok, err = stream:feed(data); + if ok then return; end + (session.log or log)("warn", "Received invalid XML: %s", data); + (session.log or log)("warn", "Problem was: %s", err); + session:close("not-well-formed"); + end + end + + session.close = session_close; + + local handlestanza = stream_callbacks.handlestanza; + function session.dispatch_stanza(session, stanza) + return handlestanza(session, stanza); + end + + add_task(connect_timeout, function () + if session.type == "s2sin" or session.type == "s2sout" then + return; -- Ok, we're connected + elseif session.type == "s2s_destroyed" then + return; -- Session already destroyed + end + -- Not connected, need to close session and clean up + (session.log or log)("debug", "Destroying incomplete session %s->%s due to inactivity", + session.from_host or "(unknown)", session.to_host or "(unknown)"); + session:close("connection-timeout"); + end); +end + +function listener.onconnect(conn) + conn:setoption("keepalive", opt_keepalives); + local session = sessions[conn]; + if not session then -- New incoming connection + session = s2s_new_incoming(conn); + sessions[conn] = session; + session.log("debug", "Incoming s2s connection"); + + local filter = initialize_filters(session); + local w = conn.write; + session.sends2s = function (t) + log("debug", "sending: %s", t.top_tag and t:top_tag() or t:match("^([^>]*>?)")); + if t.name then + t = filter("stanzas/out", t); + end + if t then + t = filter("bytes/out", tostring(t)); + if t then + return w(conn, t); + end + end + end + + initialize_session(session); + else -- Outgoing session connected + session:open_stream(session.from_host, session.to_host); + end +end + +function listener.onincoming(conn, data) + local session = sessions[conn]; + if session then + session.data(data); + end +end + +function listener.onstatus(conn, status) + if status == "ssl-handshake-complete" then + local session = sessions[conn]; + if session and session.direction == "outgoing" then + session.log("debug", "Sending stream header..."); + session:open_stream(session.from_host, session.to_host); + end + end +end + +function listener.ondisconnect(conn, err) + local session = sessions[conn]; + if session then + sessions[conn] = nil; + 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 + (session.log or log)("debug", "...so we're going to try another target"); + return; -- Session lives for now + end + end + (session.log or log)("debug", "s2s disconnected: %s->%s (%s)", tostring(session.from_host), tostring(session.to_host), tostring(err or "connection closed")); + s2s_destroy_session(session, err); + end +end + +function listener.register_outgoing(conn, session) + session.direction = "outgoing"; + sessions[conn] = session; + initialize_session(session); +end + +function check_auth_policy(event) + local host, session = event.host, event.session; + local must_secure = secure_auth; + + if not must_secure and secure_domains[host] then + must_secure = true; + elseif must_secure and insecure_domains[host] then + must_secure = false; + end + + if must_secure and not session.cert_identity_status then + module:log("warn", "Forbidding insecure connection to/from %s", 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 + 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 + session:close{ condition = "system-shutdown", text = reason }; + end +end,500); + + + +module:provides("net", { + name = "s2s"; + listener = listener; + default_port = 5269; + encryption = "starttls"; + multiplex = { + pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:server%1.*>"; + }; +}); + diff --git a/plugins/mod_s2s/s2sout.lib.lua b/plugins/mod_s2s/s2sout.lib.lua new file mode 100644 index 00000000..cb2f8be4 --- /dev/null +++ b/plugins/mod_s2s/s2sout.lib.lua @@ -0,0 +1,361 @@ +-- 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 + +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 dns = require "net.dns"; +local t_insert, t_sort, ipairs = table.insert, table.sort, ipairs; +local st = require "util.stanza"; + +local s2s_destroy_session = require "core.s2smanager".destroy_session; + +local log = module._log; + +local sources = {}; +local has_ipv4, has_ipv6; + +local dns_timeout = module:get_option_number("dns_timeout", 15); +dns.settimeout(dns_timeout); +local max_dns_depth = module:get_option_number("dns_max_depth", 3); + +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) + initialize_filters(host_session); + host_session.version = 1; + host_session.open_stream = session_open_stream; + + -- 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", tostring(host_session.to_host)); + buffer[#buffer+1] = data; + log("debug", "Buffered item %d: %s", #buffer, tostring(data)); + end + end +end + +function s2sout.attempt_connection(host_session, err) + local from_host, to_host = host_session.from_host, host_session.to_host; + local connect_host, connect_port = to_host and idna_to_ascii(to_host), 5269; + + 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; + local handle; + handle = adns.lookup(function (answer) + handle = nil; + host_session.connecting = nil; + if answer and #answer > 0 then + log("debug", "%s has SRV records, handling...", to_host); + local srv_hosts = { answer = answer }; + host_session.srv_hosts = srv_hosts; + 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", tostring(err), host_session.srv_choice, connect_host, connect_port); + else + host_session.log("info", "Out of connection options, can't connect to %s", tostring(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 :(", tostring(connect_host), tostring(connect_port), tostring(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; + + if not err then + local IPs = {}; + host_session.ip_hosts = IPs; + local handle4, handle6; + local have_other_result = not(has_ipv4) or not(has_ipv6) or false; + + if has_ipv4 then + handle4 = adns.lookup(function (reply, err) + handle4 = nil; + + -- COMPAT: This is a compromise for all you CNAME-(ab)users :) + if not (reply and reply[#reply] and reply[#reply].a) then + local count = max_dns_depth; + reply = dns.peek(connect_host, "CNAME", "IN"); + while count > 0 and reply and reply[#reply] and not reply[#reply].a and reply[#reply].cname do + log("debug", "Looking up %s (DNS depth is %d)", tostring(reply[#reply].cname), count); + reply = dns.peek(reply[#reply].cname, "A", "IN") or dns.peek(reply[#reply].cname, "CNAME", "IN"); + count = count - 1; + end + end + -- end of CNAME resolving + + 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 + 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 = adns.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 + 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 + 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) + (host_session.log or log)("info", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port); + -- Ok, we're going to try to connect + + local from_host, to_host = host_session.from_host, host_session.to_host; + + -- Reset secure flag in case this is another + -- connection attempt after a failed STARTTLS + host_session.secure = nil; + + local conn, handler; + if connect_host.proto == "IPv4" then + conn, handler = socket.tcp(); + else + conn, handler = socket.tcp6(); + 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, "*a"); + host_session.conn = conn; + + local filter = initialize_filters(host_session); + local w, log = conn.write, host_session.log; + host_session.sends2s = function (t) + log("debug", "sending: %s", (t.top_tag and t:top_tag()) or t:match("^[^>]*>?")); + if t.name then + t = filter("stanzas/out", t); + end + if t then + t = filter("bytes/out", tostring(t)); + if t then + return w(conn, tostring(t)); + end + end + end + + -- 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 + if not socket.local_addresses then + sources[#sources + 1] = new_ip("0.0.0.0", "IPv4"); + else + for _, addr in ipairs(socket.local_addresses("ipv4", true)) do + sources[#sources + 1] = new_ip(addr, "IPv4"); + end + end + elseif source == "::" then + if not socket.local_addresses then + sources[#sources + 1] = new_ip("::", "IPv6"); + else + for _, addr in ipairs(socket.local_addresses("ipv6", true)) do + sources[#sources + 1] = new_ip(addr, "IPv6"); + end + 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 +end); + +return s2sout; diff --git a/plugins/mod_saslauth.lua b/plugins/mod_saslauth.lua index 8ef1e09d..201cc477 100644 --- a/plugins/mod_saslauth.lua +++ b/plugins/mod_saslauth.lua @@ -1,113 +1,299 @@ +-- 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. +-- + + local st = require "util.stanza"; -local send = require "core.sessionmanager".send_to_session; 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 cert_verify_identity = require "util.x509".verify_identity; -local usermanager_validate_credentials = require "core.usermanager".validate_credentials; -local t_concat, t_insert = table.concat, table.insert; +local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler; local tostring = tostring; -local log = require "util.logger".init("mod_saslauth"); +local secure_auth_only = module:get_option("c2s_require_encryption") or module:get_option("require_encryption"); +local allow_unencrypted_plain_auth = module:get_option("allow_unencrypted_plain_auth") + +local log = module._log; local xmlns_sasl ='urn:ietf:params:xml:ns:xmpp-sasl'; local xmlns_bind ='urn:ietf:params:xml:ns:xmpp-bind'; -local xmlns_stanzas ='urn:ietf:params:xml:ns:xmpp-stanzas'; - -local new_connhandler = require "net.connhandlers".new; -local new_sasl = require "util.sasl".new; - -add_handler("c2s_unauthed", "auth", xmlns_sasl, - function (session, stanza) - if not session.sasl_handler then - session.sasl_handler = new_sasl(stanza.attr.mechanism, - function (username, password) - -- onAuth - require "core.usermanager" - if usermanager_validate_credentials(session.host, username, password) then - return true; - end - return false; - end, - function (username) - -- onSuccess - local success, err = sessionmanager.make_authenticated(session, username); - if not success then - sessionmanager.destroy_session(session); - return; - end - session.sasl_handler = nil; - session.connhandler = new_connhandler("xmpp-client", session); - session.notopen = true; - end, - function (reason) - -- onFail - log("debug", "SASL failure, reason: %s", reason); - end, - function (stanza) - -- onWrite - log("debug", "SASL writes: %s", tostring(stanza)); - send(session, stanza); - end - ); - session.sasl_handler:feed(stanza); - else - error("Client tried to negotiate SASL again", 0); - end - - end); - -add_event_hook("stream-features", - function (session, features) - if not session.username then - t_insert(features, "<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>"); - t_insert(features, "<mechanism>PLAIN</mechanism>"); - t_insert(features, "</mechanisms>"); - else - t_insert(features, "<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><required/></bind>"); - t_insert(features, "<session xmlns='urn:ietf:params:xml:ns:xmpp-session'/>"); - end - --send [[<register xmlns="http://jabber.org/features/iq-register"/> ]] - end); - -add_iq_handler("c2s", "urn:ietf:params:xml:ns:xmpp-bind", - function (session, stanza) - log("debug", "Client tried to bind to a resource"); - local resource; - if stanza.attr.type == "set" then - local bind = stanza.tags[1]; - - if bind and bind.attr.xmlns == xmlns_bind then - resource = bind:child_with_name("resource"); - if resource then - resource = resource[1]; - end - end + +local function build_reply(status, ret, err_msg) + local reply = st.stanza(status, {xmlns = xmlns_sasl}); + if status == "challenge" then + --log("debug", "CHALLENGE: %s", ret or ""); + reply:text(base64.encode(ret or "")); + elseif status == "failure" then + reply:tag(ret):up(); + if err_msg then reply:tag("text"):text(err_msg); end + elseif status == "success" then + --log("debug", "SUCCESS: %s", ret or ""); + reply:text(base64.encode(ret or "")); + else + module:log("error", "Unknown sasl status: %s", status); + end + return reply; +end + +local function handle_status(session, status, ret, err_msg) + if status == "failure" then + 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); + if ok then + module:fire_event("authentication-success", { session = session }); + session.sasl_handler = nil; + session:reset_stream(); + else + module:log("warn", "SASL succeeded but username was invalid"); + module:fire_event("authentication-failure", { session = session, condition = "not-authorized", text = err }); + session.sasl_handler = session.sasl_handler:clean_clone(); + return "failure", "not-authorized", "User authenticated successfully, but username was invalid"; + end + end + return status, ret, err_msg; +end + +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")); + return true; + end + end + 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 + +module:hook_stanza(xmlns_sasl, "success", function (session, stanza) + if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end + module:log("debug", "SASL EXTERNAL with %s succeeded", session.to_host); + session.external_auth = "succeeded" + session:reset_stream(); + session:open_stream(session.from_host, session.to_host); + + module:fire_event("s2s-authenticated", { session = session, host = session.to_host }); + return true; +end) + +module:hook_stanza(xmlns_sasl, "failure", function (session, stanza) + if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end + + module:log("info", "SASL EXTERNAL with %s failed", session.to_host) + -- TODO: Log the failure reason + session.external_auth = "failed" +end, 500) + +module:hook_stanza(xmlns_sasl, "failure", function (session, stanza) + -- TODO: Dialback wasn't loaded. Do something useful. +end, 90) + +module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) + if session.type ~= "s2sout_unauthed" or not session.secure then return; end + + local mechanisms = stanza:get_child("mechanisms", xmlns_sasl) + if mechanisms then + for mech in mechanisms:childtags() do + if mech[1] == "EXTERNAL" then + module:log("debug", "Initiating SASL EXTERNAL with %s", session.to_host); + local reply = st.stanza("auth", {xmlns = xmlns_sasl, mechanism = "EXTERNAL"}); + reply:text(base64.encode(session.from_host)) + session.sends2s(reply) + session.external_auth = "attempting" + return true end - local success, err = sm_bind_resource(session, resource); - if not success then - local reply = st.reply(stanza); - reply.attr.type = "error"; - if err == "conflict" then - reply:tag("error", { type = "modify" }) - :tag("conflict", { xmlns = xmlns_stanzas }); - elseif err == "constraint" then - reply:tag("error", { type = "cancel" }) - :tag("resource-constraint", { xmlns = xmlns_stanzas }); - elseif err == "auth" then - reply:tag("error", { type = "cancel" }) - :tag("not-allowed", { xmlns = xmlns_stanzas }); - end - send(session, reply); - else - local reply = st.reply(stanza); - reply:tag("bind", { xmlns = xmlns_bind}) - :tag("jid"):text(session.full_jid); - send(session, reply); + end + end +end, 150); + +local function s2s_external_auth(session, stanza) + local mechanism = stanza.attr.mechanism; + + if not session.secure then + if mechanism == "EXTERNAL" then + session.sends2s(build_reply("failure", "encryption-required")) + else + session.sends2s(build_reply("failure", "invalid-mechanism")) + end + return true; + end + + if mechanism ~= "EXTERNAL" or session.cert_chain_status ~= "valid" then + session.sends2s(build_reply("failure", "invalid-mechanism")) + return true; + end + + local text = stanza[1] + if not text then + session.sends2s(build_reply("failure", "malformed-request")) + return true + end + + -- Either the value is "=" and we've already verified the external + -- cert identity, or the value is a string and either matches the + -- from_host ( + + text = base64.decode(text) + if not text then + session.sends2s(build_reply("failure", "incorrect-encoding")) + return true; + end + + if session.cert_identity_status == "valid" then + if text ~= "" and text ~= session.from_host then + session.sends2s(build_reply("failure", "invalid-authzid")) + return true + end + else + if text == "" then + session.sends2s(build_reply("failure", "invalid-authzid")) + return true + end + + local cert = session.conn:socket():getpeercertificate() + if (cert_verify_identity(text, "xmpp-server", cert)) then + session.cert_identity_status = "valid" + else + session.cert_identity_status = "invalid" + session.sends2s(build_reply("failure", "invalid-authzid")) + return true + end + end + + session.external_auth = "succeeded" + + if not session.from_host then + session.from_host = text; + end + session.sends2s(build_reply("success")) + + local domain = text ~= "" and text or session.from_host; + module:log("info", "Accepting SASL EXTERNAL identity from %s", domain); + module:fire_event("s2s-authenticated", { session = session, host = domain }); + session:reset_stream(); + return true +end + +module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:auth", function(event) + local session, stanza = event.origin, event.stanza; + if session.type == "s2sin_unauthed" then + return s2s_external_auth(session, stanza) + end + + if session.type ~= "c2s_unauthed" then return; end + + if session.sasl_handler and session.sasl_handler.selected then + session.sasl_handler = nil; -- allow starting a new SASL negotiation before completing an old one + end + if not session.sasl_handler then + session.sasl_handler = usermanager_get_sasl_handler(module.host, session); + end + local mechanism = stanza.attr.mechanism; + if not session.secure and (secure_auth_only or (mechanism == "PLAIN" and not allow_unencrypted_plain_auth)) then + session.send(build_reply("failure", "encryption-required")); + return true; + end + local valid_mechanism = session.sasl_handler:select(mechanism); + if not valid_mechanism then + session.send(build_reply("failure", "invalid-mechanism")); + return true; + end + return sasl_process_cdata(session, stanza); +end); +module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:response", function(event) + local session = event.origin; + if not(session.sasl_handler and session.sasl_handler.selected) then + session.send(build_reply("failure", "not-authorized", "Out of order SASL element")); + return true; + end + return sasl_process_cdata(session, event.stanza); +end); +module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:abort", function(event) + local session = event.origin; + session.sasl_handler = nil; + session.send(build_reply("failure", "aborted")); + return true; +end); + +local mechanisms_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-sasl' }; +local bind_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-bind' }; +local xmpp_session_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-session' }; +module:hook("stream-features", function(event) + local origin, features = event.origin, event.features; + if not origin.username then + if secure_auth_only and not origin.secure then + return; + end + origin.sasl_handler = usermanager_get_sasl_handler(module.host, origin); + local mechanisms = st.stanza("mechanisms", mechanisms_attr); + for mechanism in pairs(origin.sasl_handler:mechanisms()) do + if mechanism ~= "PLAIN" or origin.secure or allow_unencrypted_plain_auth then + mechanisms:tag("mechanism"):text(mechanism):up(); end - end); - -add_iq_handler("c2s", "urn:ietf:params:xml:ns:xmpp-session", - function (session, stanza) - log("debug", "Client tried to bind to a resource"); - send(session, st.reply(stanza)); - end); + end + if mechanisms[1] then features:add_child(mechanisms); end + else + features:tag("bind", bind_attr):tag("required"):up():up(); + features:tag("session", xmpp_session_attr):tag("optional"):up():up(); + end +end); + +module:hook("s2s-stream-features", function(event) + local origin, features = event.origin, event.features; + if origin.secure and origin.type == "s2sin_unauthed" then + -- Offer EXTERNAL if chain is valid and either we didn't validate + -- the identity or it passed. + if origin.cert_chain_status == "valid" and origin.cert_identity_status ~= "invalid" then --TODO: Configurable + module:log("debug", "Offering SASL EXTERNAL") + features:tag("mechanisms", { xmlns = xmlns_sasl }) + :tag("mechanism"):text("EXTERNAL") + :up():up(); + end + end +end); + +module:hook("iq/self/urn:ietf:params:xml:ns:xmpp-bind:bind", function(event) + local origin, stanza = event.origin, event.stanza; + local resource; + if stanza.attr.type == "set" then + local bind = stanza.tags[1]; + resource = bind:child_with_name("resource"); + resource = resource and #resource.tags == 0 and resource[1] or nil; + end + local success, err_type, err, err_msg = sm_bind_resource(origin, resource); + if success then + origin.send(st.reply(stanza) + :tag("bind", { xmlns = xmlns_bind }) + :tag("jid"):text(origin.full_jid)); + origin.log("debug", "Resource bound: %s", origin.full_jid); + else + origin.send(st.error_reply(stanza, err_type, err, err_msg)); + origin.log("debug", "Resource bind failed: %s", err_msg or err); + end + return true; +end); + +local function handle_legacy_session(event) + event.origin.send(st.reply(event.stanza)); + return true; +end + +module:hook("iq/self/urn:ietf:params:xml:ns:xmpp-session:session", handle_legacy_session); +module:hook("iq/host/urn:ietf:params:xml:ns:xmpp-session:session", handle_legacy_session); diff --git a/plugins/mod_storage_internal.lua b/plugins/mod_storage_internal.lua new file mode 100644 index 00000000..972ecbee --- /dev/null +++ b/plugins/mod_storage_internal.lua @@ -0,0 +1,31 @@ +local datamanager = require "core.storagemanager".olddm; + +local host = module.host; + +local driver = {}; +local driver_mt = { __index = driver }; + +function driver:open(store, typ) + return setmetatable({ store = store, type = typ }, driver_mt); +end +function driver:get(user) + return datamanager.load(user, host, self.store); +end + +function driver:set(user, data) + return datamanager.store(user, host, self.store, data); +end + +function driver:stores(username) + return datamanager.stores(username, host); +end + +function driver:users() + return datamanager.users(host, self.store, self.type); +end + +function driver:purge(user) + return datamanager.purge(user, host); +end + +module:provides("storage", driver); diff --git a/plugins/mod_storage_none.lua b/plugins/mod_storage_none.lua new file mode 100644 index 00000000..8f2d2f56 --- /dev/null +++ b/plugins/mod_storage_none.lua @@ -0,0 +1,23 @@ +local driver = {}; +local driver_mt = { __index = driver }; + +function driver:open(store) + return setmetatable({ store = store }, driver_mt); +end +function driver:get(user) + return {}; +end + +function driver:set(user, data) + return nil, "Storage disabled"; +end + +function driver:stores(username) + return { "roster" }; +end + +function driver:purge(user) + return true; +end + +module:provides("storage", driver); diff --git a/plugins/mod_storage_sql.lua b/plugins/mod_storage_sql.lua new file mode 100644 index 00000000..eed3fec9 --- /dev/null +++ b/plugins/mod_storage_sql.lua @@ -0,0 +1,414 @@ + +--[[ + +DB Tables: + Prosody - key-value, map + | host | user | store | key | type | value | + ProsodyArchive - list + | host | user | store | key | time | stanzatype | jsonvalue | + +Mapping: + Roster - Prosody + | host | user | "roster" | "contactjid" | type | value | + | host | user | "roster" | NULL | "json" | roster[false] data | + Account - Prosody + | host | user | "accounts" | "username" | type | value | + + Offline - ProsodyArchive + | host | user | "offline" | "contactjid" | time | "message" | json|XML | + +]] + +local type = type; +local tostring = tostring; +local tonumber = tonumber; +local pairs = pairs; +local next = next; +local setmetatable = setmetatable; +local xpcall = xpcall; +local json = require "util.json"; +local build_url = require"socket.url".build; + +local DBI; +local connection; +local host,user,store = module.host; +local params = module:get_option("sql"); + +local dburi; +local connections = module:shared "/*/sql/connection-cache"; + +local function db2uri(params) + return build_url{ + scheme = params.driver, + user = params.username, + password = params.password, + host = params.host, + port = params.port, + path = params.database, + }; +end + + +local resolve_relative_path = require "core.configmanager".resolve_relative_path; + +local function test_connection() + if not connection then return nil; end + if connection:ping() then + return true; + else + module:log("debug", "Database connection closed"); + connection = nil; + connections[dburi] = nil; + end +end +local function connect() + if not test_connection() then + prosody.unlock_globals(); + local dbh, err = DBI.Connect( + params.driver, params.database, + params.username, params.password, + params.host, params.port + ); + prosody.lock_globals(); + if not dbh then + module:log("debug", "Database connection failed: %s", tostring(err)); + return nil, err; + end + module:log("debug", "Successfully connected to database"); + dbh:autocommit(false); -- don't commit automatically + connection = dbh; + + connections[dburi] = dbh; + end + return connection; +end + +local function create_table() + if not module:get_option("sql_manage_tables", true) then + return; + end + local create_sql = "CREATE TABLE `prosody` (`host` TEXT, `user` TEXT, `store` TEXT, `key` TEXT, `type` TEXT, `value` TEXT);"; + if params.driver == "PostgreSQL" then + create_sql = create_sql:gsub("`", "\""); + elseif params.driver == "MySQL" then + create_sql = create_sql:gsub("`value` TEXT", "`value` MEDIUMTEXT"); + end + + local stmt, err = connection:prepare(create_sql); + if stmt then + local ok = stmt:execute(); + local commit_ok = connection:commit(); + if ok and commit_ok then + module:log("info", "Initialized new %s database with prosody table", params.driver); + local index_sql = "CREATE INDEX `prosody_index` ON `prosody` (`host`, `user`, `store`, `key`)"; + if params.driver == "PostgreSQL" then + index_sql = index_sql:gsub("`", "\""); + elseif params.driver == "MySQL" then + index_sql = index_sql:gsub("`([,)])", "`(20)%1"); + end + local stmt, err = connection:prepare(index_sql); + local ok, commit_ok, commit_err; + if stmt then + ok, err = stmt:execute(); + commit_ok, commit_err = connection:commit(); + end + if not(ok and commit_ok) then + module:log("warn", "Failed to create index (%s), lookups may not be optimised", err or commit_err); + end + elseif params.driver == "MySQL" then -- COMPAT: Upgrade tables from 0.8.0 + -- Failed to create, but check existing MySQL table here + local stmt = connection:prepare("SHOW COLUMNS FROM prosody WHERE Field='value' and Type='text'"); + local ok = stmt:execute(); + local commit_ok = connection:commit(); + if ok and commit_ok then + if stmt:rowcount() > 0 then + module:log("info", "Upgrading database schema..."); + local stmt = connection:prepare("ALTER TABLE prosody MODIFY COLUMN `value` MEDIUMTEXT"); + local ok, err = stmt:execute(); + local commit_ok = connection:commit(); + if ok and commit_ok then + module:log("info", "Database table automatically upgraded"); + else + module:log("error", "Failed to upgrade database schema (%s), please see " + .."http://prosody.im/doc/mysql for help", + err or "unknown error"); + end + end + repeat until not stmt:fetch(); + end + end + elseif params.driver ~= "SQLite3" then -- SQLite normally fails to prepare for existing table + module:log("warn", "Prosody was not able to automatically check/create the database table (%s), " + .."see http://prosody.im/doc/modules/mod_storage_sql#table_management for help.", + err or "unknown error"); + end +end + +do -- process options to get a db connection + local ok; + prosody.unlock_globals(); + ok, DBI = pcall(require, "DBI"); + if not ok then + package.loaded["DBI"] = {}; + module:log("error", "Failed to load the LuaDBI library for accessing SQL databases: %s", DBI); + module:log("error", "More information on installing LuaDBI can be found at http://prosody.im/doc/depends#luadbi"); + end + prosody.lock_globals(); + if not ok or not DBI.Connect then + return; -- Halt loading of this module + end + + params = params or { driver = "SQLite3" }; + + if params.driver == "SQLite3" then + params.database = resolve_relative_path(prosody.paths.data or ".", params.database or "prosody.sqlite"); + end + + assert(params.driver and params.database, "Both the SQL driver and the database need to be specified"); + + dburi = db2uri(params); + connection = connections[dburi]; + + assert(connect()); + + -- Automatically create table, ignore failure (table probably already exists) + create_table(); +end + +local function serialize(value) + local t = type(value); + if t == "string" or t == "boolean" or t == "number" then + return t, tostring(value); + elseif t == "table" then + local value,err = json.encode(value); + if value then return "json", value; end + return nil, err; + end + return nil, "Unhandled value type: "..t; +end +local function deserialize(t, value) + if t == "string" then return value; + elseif t == "boolean" then + if value == "true" then return true; + elseif value == "false" then return false; end + elseif t == "number" then return tonumber(value); + elseif t == "json" then + return json.decode(value); + end +end + +local function dosql(sql, ...) + if params.driver == "PostgreSQL" then + sql = sql:gsub("`", "\""); + end + -- do prepared statement stuff + local stmt, err = connection:prepare(sql); + if not stmt and not test_connection() then error("connection failed"); end + if not stmt then module:log("error", "QUERY FAILED: %s %s", err, debug.traceback()); return nil, err; end + -- run query + local ok, err = stmt:execute(...); + if not ok and not test_connection() then error("connection failed"); end + if not ok then return nil, err; end + + return stmt; +end +local function getsql(sql, ...) + return dosql(sql, host or "", user or "", store or "", ...); +end +local function setsql(sql, ...) + local stmt, err = getsql(sql, ...); + if not stmt then return stmt, err; end + return stmt:affected(); +end +local function transact(...) + -- ... +end +local function rollback(...) + if connection then connection:rollback(); end -- FIXME check for rollback error? + return ...; +end +local function commit(...) + local success,err = connection:commit(); + if not success then return nil, "SQL commit failed: "..tostring(err); end + return ...; +end + +local function keyval_store_get() + local stmt, err = getsql("SELECT * FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=?"); + if not stmt then return rollback(nil, err); end + + local haveany; + local result = {}; + for row in stmt:rows(true) do + haveany = true; + local k = row.key; + local v = deserialize(row.type, row.value); + if k and v then + if k ~= "" then result[k] = v; elseif type(v) == "table" then + for a,b in pairs(v) do + result[a] = b; + end + end + end + end + return commit(haveany and result or nil); +end +local function keyval_store_set(data) + local affected, err = setsql("DELETE FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=?"); + if not affected then return rollback(affected, err); end + + if data and next(data) ~= nil then + local extradata = {}; + for key, value in pairs(data) do + if type(key) == "string" and key ~= "" then + local t, value = serialize(value); + if not t then return rollback(t, value); end + local ok, err = setsql("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", key, t, value); + if not ok then return rollback(ok, err); end + else + extradata[key] = value; + end + end + if next(extradata) ~= nil then + local t, extradata = serialize(extradata); + if not t then return rollback(t, extradata); end + local ok, err = setsql("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", "", t, extradata); + if not ok then return rollback(ok, err); end + end + end + return commit(true); +end + +local keyval_store = {}; +keyval_store.__index = keyval_store; +function keyval_store:get(username) + user,store = username,self.store; + if not connection and not connect() then return nil, "Unable to connect to database"; end + local success, ret, err = xpcall(keyval_store_get, debug.traceback); + if not connection and connect() then + success, ret, err = xpcall(keyval_store_get, debug.traceback); + end + if success then return ret, err; else return rollback(nil, ret); end +end +function keyval_store:set(username, data) + user,store = username,self.store; + if not connection and not connect() then return nil, "Unable to connect to database"; end + local success, ret, err = xpcall(function() return keyval_store_set(data); end, debug.traceback); + if not connection and connect() then + success, ret, err = xpcall(function() return keyval_store_set(data); end, debug.traceback); + end + if success then return ret, err; else return rollback(nil, ret); end +end +function keyval_store:users() + local stmt, err = dosql("SELECT DISTINCT `user` FROM `prosody` WHERE `host`=? AND `store`=?", host, self.store); + if not stmt then + return rollback(nil, err); + end + local next = stmt:rows(); + return commit(function() + local row = next(); + return row and row[1]; + end); +end + +local function map_store_get(key) + local stmt, err = getsql("SELECT * FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=? AND `key`=?", key or ""); + if not stmt then return rollback(nil, err); end + + local haveany; + local result = {}; + for row in stmt:rows(true) do + haveany = true; + local k = row.key; + local v = deserialize(row.type, row.value); + if k and v then + if k ~= "" then result[k] = v; elseif type(v) == "table" then + for a,b in pairs(v) do + result[a] = b; + end + end + end + end + return commit(haveany and result[key] or nil); +end +local function map_store_set(key, data) + local affected, err = setsql("DELETE FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=? AND `key`=?", key or ""); + if not affected then return rollback(affected, err); end + + if data and next(data) ~= nil then + if type(key) == "string" and key ~= "" then + local t, value = serialize(data); + if not t then return rollback(t, value); end + local ok, err = setsql("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", key, t, value); + if not ok then return rollback(ok, err); end + else + -- TODO non-string keys + end + end + return commit(true); +end + +local map_store = {}; +map_store.__index = map_store; +function map_store:get(username, key) + user,store = username,self.store; + local success, ret, err = xpcall(function() return map_store_get(key); end, debug.traceback); + if success then return ret, err; else return rollback(nil, ret); end +end +function map_store:set(username, key, data) + user,store = username,self.store; + local success, ret, err = xpcall(function() return map_store_set(key, data); end, debug.traceback); + if success then return ret, err; else return rollback(nil, ret); end +end + +local list_store = {}; +list_store.__index = list_store; +function list_store:scan(username, from, to, jid, typ) + user,store = username,self.store; + + local cols = {"from", "to", "jid", "typ"}; + local vals = { from , to , jid , typ }; + local stmt, err; + local query = "SELECT * FROM `prosodyarchive` WHERE `host`=? AND `user`=? AND `store`=?"; + + query = query.." ORDER BY time"; + --local stmt, err = getsql("SELECT * FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=? AND `key`=?", key or ""); + + return nil, "not-implemented" +end + +local driver = {}; + +function driver:open(store, typ) + if not typ then -- default key-value store + return setmetatable({ store = store }, keyval_store); + end + return nil, "unsupported-store"; +end + +function driver:stores(username) + local sql = "SELECT DISTINCT `store` FROM `prosody` WHERE `host`=? AND `user`" .. + (username == true and "!=?" or "=?"); + if username == true or not username then + username = ""; + end + local stmt, err = dosql(sql, host, username); + if not stmt then + return rollback(nil, err); + end + local next = stmt:rows(); + return commit(function() + local row = next(); + return row and row[1]; + end); +end + +function driver:purge(username) + local stmt, err = dosql("DELETE FROM `prosody` WHERE `host`=? AND `user`=?", host, username); + if not stmt then return rollback(stmt, err); end + local changed, err = stmt:affected(); + if not changed then return rollback(changed, err); end + return commit(true, changed); +end + +module:provides("storage", driver); diff --git a/plugins/mod_storage_sql2.lua b/plugins/mod_storage_sql2.lua new file mode 100644 index 00000000..7d705b0b --- /dev/null +++ b/plugins/mod_storage_sql2.lua @@ -0,0 +1,237 @@ + +local json = require "util.json"; +local resolve_relative_path = require "core.configmanager".resolve_relative_path; + +local mod_sql = module:require("sql"); +local params = module:get_option("sql"); + +local engine; -- TODO create engine + +local function create_table() + --[[local Table,Column,Index = mod_sql.Table,mod_sql.Column,mod_sql.Index; + local ProsodyTable = Table { + name="prosody"; + Column { name="host", type="TEXT", nullable=false }; + Column { name="user", type="TEXT", nullable=false }; + Column { name="store", type="TEXT", nullable=false }; + Column { name="key", type="TEXT", nullable=false }; + Column { name="type", type="TEXT", nullable=false }; + Column { name="value", type="TEXT", nullable=false }; + Index { name="prosody_index", "host", "user", "store", "key" }; + }; + engine:transaction(function() + ProsodyTable:create(engine); + end);]] + if not module:get_option("sql_manage_tables", true) then + return; + end + + local create_sql = "CREATE TABLE `prosody` (`host` TEXT, `user` TEXT, `store` TEXT, `key` TEXT, `type` TEXT, `value` TEXT);"; + if params.driver == "PostgreSQL" then + create_sql = create_sql:gsub("`", "\""); + elseif params.driver == "MySQL" then + create_sql = create_sql:gsub("`value` TEXT", "`value` MEDIUMTEXT") + :gsub(";$", " CHARACTER SET 'utf8' COLLATE 'utf8_bin';"); + end + + local index_sql = "CREATE INDEX `prosody_index` ON `prosody` (`host`, `user`, `store`, `key`)"; + if params.driver == "PostgreSQL" then + index_sql = index_sql:gsub("`", "\""); + elseif params.driver == "MySQL" then + index_sql = index_sql:gsub("`([,)])", "`(20)%1"); + end + + local success,err = engine:transaction(function() + engine:execute(create_sql); + engine:execute(index_sql); + end); + if not success then -- so we failed to create + if params.driver == "MySQL" then + success,err = engine:transaction(function() + local result = engine:execute("SHOW COLUMNS FROM prosody WHERE Field='value' and Type='text'"); + if result:rowcount() > 0 then + module:log("info", "Upgrading database schema..."); + engine:execute("ALTER TABLE prosody MODIFY COLUMN `value` MEDIUMTEXT"); + module:log("info", "Database table automatically upgraded"); + end + return true; + end); + if not success then + module:log("error", "Failed to check/upgrade database schema (%s), please see " + .."http://prosody.im/doc/mysql for help", + err or "unknown error"); + end + end + end +end +local function set_encoding() + if params.driver ~= "SQLite3" then + local set_names_query = "SET NAMES 'utf8';"; + if params.driver == "MySQL" then + set_names_query = set_names_query:gsub(";$", " COLLATE 'utf8_bin';"); + end + local success,err = engine:transaction(function() return engine:execute(set_names_query); end); + if not success then + module:log("error", "Failed to set database connection encoding to UTF8: %s", err); + return; + end + if params.driver == "MySQL" then + -- COMPAT w/pre-0.9: Upgrade tables to UTF-8 if not already + local check_encoding_query = "SELECT `COLUMN_NAME`,`COLUMN_TYPE` FROM `information_schema`.`columns` WHERE `TABLE_NAME`='prosody' AND ( `CHARACTER_SET_NAME`!='utf8' OR `COLLATION_NAME`!='utf8_bin' );"; + local success,err = engine:transaction(function() + local result = engine:execute(check_encoding_query); + local n_bad_columns = result:rowcount(); + if n_bad_columns > 0 then + module:log("warn", "Found %d columns in prosody table requiring encoding change, updating now...", n_bad_columns); + local fix_column_query1 = "ALTER TABLE `prosody` CHANGE `%s` `%s` BLOB;"; + local fix_column_query2 = "ALTER TABLE `prosody` CHANGE `%s` `%s` %s CHARACTER SET 'utf8' COLLATE 'utf8_bin';"; + for row in success:rows() do + local column_name, column_type = unpack(row); + engine:execute(fix_column_query1:format(column_name, column_name)); + engine:execute(fix_column_query2:format(column_name, column_name, column_type)); + end + module:log("info", "Database encoding upgrade complete!"); + end + end); + local success,err = engine:transaction(function() return engine:execute(check_encoding_query); end); + if not success then + module:log("error", "Failed to check/upgrade database encoding: %s", err or "unknown error"); + end + end + end +end + +do -- process options to get a db connection + params = params or { driver = "SQLite3" }; + + if params.driver == "SQLite3" then + params.database = resolve_relative_path(prosody.paths.data or ".", params.database or "prosody.sqlite"); + end + + assert(params.driver and params.database, "Both the SQL driver and the database need to be specified"); + + --local dburi = db2uri(params); + engine = mod_sql:create_engine(params); + + -- Encoding mess + set_encoding(); + + -- Automatically create table, ignore failure (table probably already exists) + create_table(); +end + +local function serialize(value) + local t = type(value); + if t == "string" or t == "boolean" or t == "number" then + return t, tostring(value); + elseif t == "table" then + local value,err = json.encode(value); + if value then return "json", value; end + return nil, err; + end + return nil, "Unhandled value type: "..t; +end +local function deserialize(t, value) + if t == "string" then return value; + elseif t == "boolean" then + if value == "true" then return true; + elseif value == "false" then return false; end + elseif t == "number" then return tonumber(value); + elseif t == "json" then + return json.decode(value); + end +end + +local host = module.host; +local user, store; + +local function keyval_store_get() + local haveany; + local result = {}; + for row in engine:select("SELECT `key`,`type`,`value` FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=?", host, user, store) do + haveany = true; + local k = row[1]; + local v = deserialize(row[2], row[3]); + if k and v then + if k ~= "" then result[k] = v; elseif type(v) == "table" then + for a,b in pairs(v) do + result[a] = b; + end + end + end + end + if haveany then + return result; + end +end +local function keyval_store_set(data) + engine:delete("DELETE FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=?", host, user, store); + + if data and next(data) ~= nil then + local extradata = {}; + for key, value in pairs(data) do + if type(key) == "string" and key ~= "" then + local t, value = serialize(value); + assert(t, value); + engine:insert("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", host, user, store, key, t, value); + else + extradata[key] = value; + end + end + if next(extradata) ~= nil then + local t, extradata = serialize(extradata); + assert(t, extradata); + engine:insert("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", host, user, store, "", t, extradata); + end + end + return true; +end + +local keyval_store = {}; +keyval_store.__index = keyval_store; +function keyval_store:get(username) + user,store = username,self.store; + return select(2, engine:transaction(keyval_store_get)); +end +function keyval_store:set(username, data) + user,store = username,self.store; + return engine:transaction(function() + return keyval_store_set(data); + end); +end +function keyval_store:users() + return engine:transaction(function() + return engine:select("SELECT DISTINCT `user` FROM `prosody` WHERE `host`=? AND `store`=?", host, self.store); + end); +end + +local driver = {}; + +function driver:open(store, typ) + if not typ then -- default key-value store + return setmetatable({ store = store }, keyval_store); + end + return nil, "unsupported-store"; +end + +function driver:stores(username) + local sql = "SELECT DISTINCT `store` FROM `prosody` WHERE `host`=? AND `user`" .. + (username == true and "!=?" or "=?"); + if username == true or not username then + username = ""; + end + return engine:transaction(function() + return engine:select(sql, host, username); + end); +end + +function driver:purge(username) + return engine:transaction(function() + local stmt,err = engine:delete("DELETE FROM `prosody` WHERE `host`=? AND `user`=?", host, username); + return true,err; + end); +end + +module:provides("storage", driver); + + diff --git a/plugins/mod_time.lua b/plugins/mod_time.lua new file mode 100644 index 00000000..cb69ebe7 --- /dev/null +++ b/plugins/mod_time.lua @@ -0,0 +1,44 @@ +-- 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. +-- + +local st = require "util.stanza"; +local datetime = require "util.datetime".datetime; +local legacy = require "util.datetime".legacy; + +-- XEP-0202: Entity Time + +module:add_feature("urn:xmpp:time"); + +local function time_handler(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "get" then + origin.send(st.reply(stanza):tag("time", {xmlns="urn:xmpp:time"}) + :tag("tzo"):text("+00:00"):up() -- TODO get the timezone in a platform independent fashion + :tag("utc"):text(datetime())); + return true; + end +end + +module:hook("iq/bare/urn:xmpp:time:time", time_handler); +module:hook("iq/host/urn:xmpp:time:time", time_handler); + +-- XEP-0090: Entity Time (deprecated) + +module:add_feature("jabber:iq:time"); + +local function legacy_time_handler(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "get" then + origin.send(st.reply(stanza):tag("query", {xmlns="jabber:iq:time"}) + :tag("utc"):text(legacy())); + return true; + end +end + +module:hook("iq/bare/jabber:iq:time:query", legacy_time_handler); +module:hook("iq/host/jabber:iq:time:query", legacy_time_handler); diff --git a/plugins/mod_tls.lua b/plugins/mod_tls.lua new file mode 100644 index 00000000..80b56abb --- /dev/null +++ b/plugins/mod_tls.lua @@ -0,0 +1,107 @@ +-- 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. +-- + +local config = require "core.configmanager"; +local create_context = require "core.certmanager".create_context; +local st = require "util.stanza"; + +local secure_auth_only = module:get_option("c2s_require_encryption") or module:get_option("require_encryption"); +local secure_s2s_only = module:get_option("s2s_require_encryption"); +local allow_s2s_tls = module:get_option("s2s_allow_encryption") ~= false; + +local xmlns_starttls = 'urn:ietf:params:xml:ns:xmpp-tls'; +local starttls_attr = { xmlns = xmlns_starttls }; +local starttls_proceed = st.stanza("proceed", starttls_attr); +local starttls_failure = st.stanza("failure", starttls_attr); +local c2s_feature = st.stanza("starttls", starttls_attr); +local s2s_feature = st.stanza("starttls", starttls_attr); +if secure_auth_only then c2s_feature:tag("required"):up(); end +if secure_s2s_only then s2s_feature:tag("required"):up(); end + +local global_ssl_ctx = prosody.global_ssl_ctx; + +local hosts = prosody.hosts; +local host = hosts[module.host]; + +local function can_do_tls(session) + if session.type == "c2s_unauthed" then + return session.conn.starttls and host.ssl_ctx_in; + elseif session.type == "s2sin_unauthed" and allow_s2s_tls then + return session.conn.starttls and host.ssl_ctx_in; + elseif session.direction == "outgoing" and allow_s2s_tls then + return session.conn.starttls and host.ssl_ctx; + end + return false; +end + +-- Hook <starttls/> +module:hook("stanza/urn:ietf:params:xml:ns:xmpp-tls:starttls", function(event) + local origin = event.origin; + if can_do_tls(origin) then + (origin.sends2s or origin.send)(starttls_proceed); + origin:reset_stream(); + local host = origin.to_host or origin.host; + local ssl_ctx = host and hosts[host].ssl_ctx_in or global_ssl_ctx; + origin.conn:starttls(ssl_ctx); + origin.log("debug", "TLS negotiation started for %s...", origin.type); + origin.secure = false; + else + origin.log("warn", "Attempt to start TLS, but TLS is not available on this %s connection", origin.type); + (origin.sends2s or origin.send)(starttls_failure); + origin:close(); + end + return true; +end); + +-- Advertize stream feature +module:hook("stream-features", function(event) + local origin, features = event.origin, event.features; + if can_do_tls(origin) then + features:add_child(c2s_feature); + end +end); +module:hook("s2s-stream-features", function(event) + local origin, features = event.origin, event.features; + if can_do_tls(origin) then + features:add_child(s2s_feature); + end +end); + +-- For s2sout connections, start TLS if we can +module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) + module:log("debug", "Received features element"); + if can_do_tls(session) and stanza:child_with_ns(xmlns_starttls) then + module:log("debug", "%s is offering TLS, taking up the offer...", session.to_host); + session.sends2s("<starttls xmlns='"..xmlns_starttls.."'/>"); + return true; + end +end, 500); + +module:hook_stanza(xmlns_starttls, "proceed", function (session, stanza) + module:log("debug", "Proceeding with TLS on s2sout..."); + session:reset_stream(); + local ssl_ctx = session.from_host and hosts[session.from_host].ssl_ctx or global_ssl_ctx; + session.conn:starttls(ssl_ctx); + session.secure = false; + return true; +end); + +function module.load() + local ssl_config = config.rawget(module.host, "ssl"); + if not ssl_config then + local base_host = module.host:match("%.(.*)"); + ssl_config = config.get(base_host, "ssl"); + end + host.ssl_ctx = create_context(host.host, "client", ssl_config); -- for outgoing connections + host.ssl_ctx_in = create_context(host.host, "server", ssl_config); -- for incoming connections +end + +function module.unload() + host.ssl_ctx = nil; + host.ssl_ctx_in = nil; +end diff --git a/plugins/mod_uptime.lua b/plugins/mod_uptime.lua new file mode 100644 index 00000000..3f275b2f --- /dev/null +++ b/plugins/mod_uptime.lua @@ -0,0 +1,48 @@ +-- 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. +-- + +local st = require "util.stanza"; + +local start_time = prosody.start_time; +module:hook_global("server-started", function() start_time = prosody.start_time end); + +-- XEP-0012: Last activity +module:add_feature("jabber:iq:last"); + +module:hook("iq/host/jabber:iq:last:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "get" then + origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))})); + return true; + end +end); + +-- Ad-hoc command +local adhoc_new = module:require "adhoc".new; + +function uptime_text() + local t = os.time()-prosody.start_time; + local seconds = t%60; + t = (t - seconds)/60; + local minutes = t%60; + t = (t - minutes)/60; + local hours = t%24; + t = (t - hours)/24; + local days = t; + return string.format("This server has been running for %d day%s, %d hour%s and %d minute%s (since %s)", + days, (days ~= 1 and "s") or "", hours, (hours ~= 1 and "s") or "", + minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time)); +end + +function uptime_command_handler (self, data, state) + return { info = uptime_text(), status = "completed" }; +end + +local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler); + +module:add_item ("adhoc", descriptor); diff --git a/plugins/mod_vcard.lua b/plugins/mod_vcard.lua new file mode 100644 index 00000000..26b30e3a --- /dev/null +++ b/plugins/mod_vcard.lua @@ -0,0 +1,54 @@ +-- 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. +-- + +local st = require "util.stanza" +local jid_split = require "util.jid".split; + +local vcards = module:open_store(); + +module:add_feature("vcard-temp"); + +local function handle_vcard(event) + local session, stanza = event.origin, event.stanza; + local to = stanza.attr.to; + if stanza.attr.type == "get" then + local vCard; + if to then + local node, host = 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 + end + if vCard then + session.send(st.reply(stanza):add_child(vCard)); -- send vCard! + else + session.send(st.error_reply(stanza, "cancel", "item-not-found")); + end + else + if not to then + if vcards:set(session.username, st.preserialize(stanza.tags[1])) then + session.send(st.reply(stanza)); + else + -- TODO unable to write file, file may be locked, etc, what's the correct error? + session.send(st.error_reply(stanza, "wait", "internal-server-error")); + end + else + session.send(st.error_reply(stanza, "auth", "forbidden")); + end + end + return true; +end + +module:hook("iq/bare/vcard-temp:vCard", handle_vcard); +module:hook("iq/host/vcard-temp:vCard", handle_vcard); + +-- COMPAT w/0.8 +if module:get_option("vcard_compatibility") ~= nil then + module:log("error", "The vcard_compatibility option has been removed, see".. + "mod_compat_vcard in prosody-modules if you still need this."); +end diff --git a/plugins/mod_version.lua b/plugins/mod_version.lua new file mode 100644 index 00000000..d35103b6 --- /dev/null +++ b/plugins/mod_version.lua @@ -0,0 +1,48 @@ +-- 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. +-- + +local st = require "util.stanza"; + +module:add_feature("jabber:iq:version"); + +local version; + +local query = st.stanza("query", {xmlns = "jabber:iq:version"}) + :tag("name"):text("Prosody"):up() + :tag("version"):text(prosody.version):up(); + +if not module:get_option("hide_os_type") then + if os.getenv("WINDIR") then + version = "Windows"; + else + local os_version_command = module:get_option("os_version_command"); + local ok, pposix = pcall(require, "util.pposix"); + if not os_version_command and (ok and pposix and pposix.uname) then + version = pposix.uname().sysname; + end + if not version then + local uname = io.popen(os_version_command or "uname"); + if uname then + version = uname:read("*a"); + end + uname:close(); + end + end + if version then + version = version:match("^%s*(.-)%s*$") or version; + query:tag("os"):text(version):up(); + end +end + +module:hook("iq/host/jabber:iq:version:query", function(event) + local stanza = event.stanza; + if stanza.attr.type == "get" and stanza.attr.to == module.host then + event.origin.send(st.reply(stanza):add_child(query)); + return true; + end +end); diff --git a/plugins/mod_watchregistrations.lua b/plugins/mod_watchregistrations.lua new file mode 100644 index 00000000..abca90bd --- /dev/null +++ b/plugins/mod_watchregistrations.lua @@ -0,0 +1,30 @@ +-- 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. +-- + + +local host = module:get_host(); +local jid_prep = require "util.jid".prep; + +local registration_watchers = module:get_option_set("registration_watchers", module:get_option("admins", {})) / jid_prep; +local registration_notification = module:get_option("registration_notification", "User $username just registered on $host from $ip"); + +local st = require "util.stanza"; + +module:hook("user-registered", function (user) + module:log("debug", "Notifying of new registration"); + local message = st.message{ type = "chat", from = host } + :tag("body") + :text(registration_notification:gsub("%$(%w+)", function (v) + return user[v] or user.session and user.session[v] or nil; + end)); + for jid in registration_watchers do + module:log("debug", "Notifying %s", jid); + message.attr.to = jid; + module:send(message); + end +end); diff --git a/plugins/mod_welcome.lua b/plugins/mod_welcome.lua new file mode 100644 index 00000000..e498f0b3 --- /dev/null +++ b/plugins/mod_welcome.lua @@ -0,0 +1,21 @@ +-- 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. +-- + +local host = module:get_host(); +local welcome_text = module:get_option("welcome_message") or "Hello $username, welcome to the $host IM server!"; + +local st = require "util.stanza"; + +module:hook("user-registered", + function (user) + local welcome_stanza = + st.message({ to = user.username.."@"..user.host, from = host }) + :tag("body"):text(welcome_text:gsub("$(%w+)", user)); + module:send(welcome_stanza); + module:log("debug", "Welcomed user %s@%s", user.username, user.host); + end); diff --git a/plugins/muc/mod_muc.lua b/plugins/muc/mod_muc.lua new file mode 100644 index 00000000..7861092c --- /dev/null +++ b/plugins/muc/mod_muc.lua @@ -0,0 +1,229 @@ +-- 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. +-- + + +if module:get_host_type() ~= "component" then + error("MUC should be loaded as a component, please see http://prosody.im/doc/components", 0); +end + +local muc_host = module:get_host(); +local muc_name = module:get_option("name"); +if type(muc_name) ~= "string" then muc_name = "Prosody Chatrooms"; end +local restrict_room_creation = module:get_option("restrict_room_creation"); +if restrict_room_creation then + if restrict_room_creation == true then + restrict_room_creation = "admin"; + elseif restrict_room_creation ~= "admin" and restrict_room_creation ~= "local" then + restrict_room_creation = nil; + end +end +local muclib = module:require "muc"; +local muc_new_room = muclib.new_room; +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local st = require "util.stanza"; +local uuid_gen = require "util.uuid".generate; +local um_is_admin = require "core.usermanager".is_admin; +local hosts = prosody.hosts; + +rooms = {}; +local rooms = rooms; +local persistent_rooms_storage = module:open_store("persistent"); +local persistent_rooms = persistent_rooms_storage:get() or {}; +local room_configs = module:open_store("config"); + +-- Configurable options +muclib.set_max_history_length(module:get_option_number("max_history_messages")); + +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +local _set_affiliation = muc_new_room.room_mt.set_affiliation; +local _get_affiliation = muc_new_room.room_mt.get_affiliation; +function muclib.room_mt:get_affiliation(jid) + if is_admin(jid) then return "owner"; end + return _get_affiliation(self, jid); +end +function muclib.room_mt:set_affiliation(actor, jid, affiliation, callback, reason) + if is_admin(jid) then return nil, "modify", "not-acceptable"; end + return _set_affiliation(self, actor, jid, affiliation, callback, reason); +end + +local function room_route_stanza(room, stanza) module:send(stanza); end +local function room_save(room, forced) + local node = jid_split(room.jid); + persistent_rooms[room.jid] = room._data.persistent; + if room._data.persistent then + local history = room._data.history; + room._data.history = nil; + local data = { + jid = room.jid; + _data = room._data; + _affiliations = room._affiliations; + }; + room_configs:set(node, data); + room._data.history = history; + elseif forced then + room_configs:set(node, nil); + if not next(room._occupants) then -- Room empty + rooms[room.jid] = nil; + end + end + if forced then persistent_rooms_storage:set(nil, persistent_rooms); end +end + +function create_room(jid) + local room = muc_new_room(jid); + room.route_stanza = room_route_stanza; + room.save = room_save; + rooms[jid] = room; + return room; +end + +local persistent_errors = false; +for jid in pairs(persistent_rooms) do + local node = jid_split(jid); + local data = room_configs:get(node); + if data then + local room = create_room(jid); + room._data = data._data; + room._affiliations = data._affiliations; + else -- missing room data + persistent_rooms[jid] = nil; + module:log("error", "Missing data for room '%s', removing from persistent room list", jid); + persistent_errors = true; + end +end +if persistent_errors then persistent_rooms_storage:set(nil, persistent_rooms); end + +local host_room = muc_new_room(muc_host); +host_room.route_stanza = room_route_stanza; +host_room.save = room_save; + +local function get_disco_info(stanza) + return st.iq({type='result', id=stanza.attr.id, from=muc_host, to=stanza.attr.from}):query("http://jabber.org/protocol/disco#info") + :tag("identity", {category='conference', type='text', name=muc_name}):up() + :tag("feature", {var="http://jabber.org/protocol/muc"}); -- TODO cache disco reply +end +local function get_disco_items(stanza) + local reply = st.iq({type='result', id=stanza.attr.id, from=muc_host, to=stanza.attr.from}):query("http://jabber.org/protocol/disco#items"); + for jid, room in pairs(rooms) do + if not room:is_hidden() then + reply:tag("item", {jid=jid, name=room:get_name()}):up(); + end + end + return reply; -- TODO cache disco reply +end + +local function handle_to_domain(event) + local origin, stanza = event.origin, event.stanza; + local type = stanza.attr.type; + if type == "error" or type == "result" then return; end + if stanza.name == "iq" and type == "get" then + local xmlns = stanza.tags[1].attr.xmlns; + local node = stanza.tags[1].attr.node; + if xmlns == "http://jabber.org/protocol/disco#info" and not node then + origin.send(get_disco_info(stanza)); + elseif xmlns == "http://jabber.org/protocol/disco#items" and not node then + origin.send(get_disco_items(stanza)); + elseif xmlns == "http://jabber.org/protocol/muc#unique" then + origin.send(st.reply(stanza):tag("unique", {xmlns = xmlns}):text(uuid_gen())); -- FIXME Random UUIDs can theoretically have collisions + else + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); -- TODO disco/etc + end + else + host_room:handle_stanza(origin, stanza); + --origin.send(st.error_reply(stanza, "cancel", "service-unavailable", "The muc server doesn't deal with messages and presence directed at it")); + end + return true; +end + +function stanza_handler(event) + local origin, stanza = event.origin, event.stanza; + local bare = jid_bare(stanza.attr.to); + local room = rooms[bare]; + if not room then + if stanza.name ~= "presence" then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + end + if not(restrict_room_creation) or + (restrict_room_creation == "admin" and is_admin(stanza.attr.from)) or + (restrict_room_creation == "local" and select(2, jid_split(stanza.attr.from)) == module.host:gsub("^[^%.]+%.", "")) then + room = create_room(bare); + end + end + if room then + room:handle_stanza(origin, stanza); + if not next(room._occupants) and not persistent_rooms[room.jid] then -- empty, non-persistent room + rooms[bare] = nil; -- discard room + end + else + origin.send(st.error_reply(stanza, "cancel", "not-allowed")); + end + return true; +end +module:hook("iq/bare", stanza_handler, -1); +module:hook("message/bare", stanza_handler, -1); +module:hook("presence/bare", stanza_handler, -1); +module:hook("iq/full", stanza_handler, -1); +module:hook("message/full", stanza_handler, -1); +module:hook("presence/full", stanza_handler, -1); +module:hook("iq/host", handle_to_domain, -1); +module:hook("message/host", handle_to_domain, -1); +module:hook("presence/host", handle_to_domain, -1); + +hosts[module.host].send = function(stanza) -- FIXME do a generic fix + if stanza.attr.type == "result" or stanza.attr.type == "error" then + module:send(stanza); + else error("component.send only supports result and error stanzas at the moment"); end +end + +hosts[module:get_host()].muc = { rooms = rooms }; + +local saved = false; +module.save = function() + saved = true; + return {rooms = rooms}; +end +module.restore = function(data) + for jid, oldroom in pairs(data.rooms or {}) do + local room = create_room(jid); + room._jid_nick = oldroom._jid_nick; + room._occupants = oldroom._occupants; + room._data = oldroom._data; + room._affiliations = oldroom._affiliations; + end + hosts[module:get_host()].muc = { rooms = rooms }; +end + +function shutdown_room(room, stanza) + for nick, occupant in pairs(room._occupants) do + stanza.attr.from = nick; + for jid in pairs(occupant.sessions) do + stanza.attr.to = jid; + room:_route_stanza(stanza); + room._jid_nick[jid] = nil; + end + room._occupants[nick] = nil; + end +end +function shutdown_component() + if not saved then + local stanza = st.presence({type = "unavailable"}) + :tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("item", { affiliation='none', role='none' }):up(); + for roomjid, room in pairs(rooms) do + shutdown_room(room, stanza); + end + shutdown_room(host_room, stanza); + end +end +module.unload = shutdown_component; +module:hook_global("server-stopping", shutdown_component); diff --git a/plugins/muc/muc.lib.lua b/plugins/muc/muc.lib.lua new file mode 100644 index 00000000..a5aba3c8 --- /dev/null +++ b/plugins/muc/muc.lib.lua @@ -0,0 +1,1171 @@ +-- 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. +-- + +local select = select; +local pairs, ipairs = pairs, ipairs; + +local datetime = require "util.datetime"; + +local dataform = require "util.dataforms"; + +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local jid_prep = require "util.jid".prep; +local st = require "util.stanza"; +local log = require "util.logger".init("mod_muc"); +local t_insert, t_remove = table.insert, table.remove; +local setmetatable = setmetatable; +local base64 = require "util.encodings".base64; +local md5 = require "util.hashes".md5; + +local muc_domain = nil; --module:get_host(); +local default_history_length, max_history_length = 20, math.huge; + +------------ +local function filter_xmlns_from_array(array, filters) + local count = 0; + for i=#array,1,-1 do + local attr = array[i].attr; + if filters[attr and attr.xmlns] then + t_remove(array, i); + count = count + 1; + end + end + return count; +end +local function filter_xmlns_from_stanza(stanza, filters) + if filters then + if filter_xmlns_from_array(stanza.tags, filters) ~= 0 then + return stanza, filter_xmlns_from_array(stanza, filters); + end + end + return stanza, 0; +end +local presence_filters = {["http://jabber.org/protocol/muc"]=true;["http://jabber.org/protocol/muc#user"]=true}; +local function get_filtered_presence(stanza) + return filter_xmlns_from_stanza(st.clone(stanza):reset(), presence_filters); +end +local kickable_error_conditions = { + ["gone"] = true; + ["internal-server-error"] = true; + ["item-not-found"] = true; + ["jid-malformed"] = true; + ["recipient-unavailable"] = true; + ["redirect"] = true; + ["remote-server-not-found"] = true; + ["remote-server-timeout"] = true; + ["service-unavailable"] = true; + ["malformed error"] = true; +}; + +local function get_error_condition(stanza) + local _, condition = stanza:get_error(); + return condition or "malformed error"; +end + +local function is_kickable_error(stanza) + local cond = get_error_condition(stanza); + return kickable_error_conditions[cond] and cond; +end +local function getUsingPath(stanza, path, getText) + local tag = stanza; + for _, name in ipairs(path) do + if type(tag) ~= 'table' then return; end + tag = tag:child_with_name(name); + end + if tag and getText then tag = table.concat(tag); end + return tag; +end +local function getTag(stanza, path) return getUsingPath(stanza, path); end +local function getText(stanza, path) return getUsingPath(stanza, path, true); end +----------- + +local room_mt = {}; +room_mt.__index = room_mt; + +function room_mt:__tostring() + return "MUC room ("..self.jid..")"; +end + +function room_mt:get_default_role(affiliation) + if affiliation == "owner" or affiliation == "admin" then + return "moderator"; + elseif affiliation == "member" then + return "participant"; + elseif not affiliation then + if not self:is_members_only() then + return self:is_moderated() and "visitor" or "participant"; + end + end +end + +function room_mt:broadcast_presence(stanza, sid, code, nick) + stanza = get_filtered_presence(stanza); + local occupant = self._occupants[stanza.attr.from]; + stanza:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) + :tag("item", {affiliation=occupant.affiliation or "none", role=occupant.role or "none", nick=nick}):up(); + if code then + stanza:tag("status", {code=code}):up(); + end + self:broadcast_except_nick(stanza, stanza.attr.from); + local me = self._occupants[stanza.attr.from]; + if me then + stanza:tag("status", {code='110'}):up(); + stanza.attr.to = sid; + self:_route_stanza(stanza); + end +end +function room_mt:broadcast_message(stanza, historic) + local to = stanza.attr.to; + for occupant, o_data in pairs(self._occupants) do + for jid in pairs(o_data.sessions) do + stanza.attr.to = jid; + self:_route_stanza(stanza); + end + end + stanza.attr.to = to; + if historic then -- add to history + local history = self._data['history']; + if not history then history = {}; self._data['history'] = history; end + stanza = st.clone(stanza); + stanza.attr.to = ""; + local stamp = datetime.datetime(); + stanza:tag("delay", {xmlns = "urn:xmpp:delay", from = muc_domain, stamp = stamp}):up(); -- XEP-0203 + stanza:tag("x", {xmlns = "jabber:x:delay", from = muc_domain, stamp = datetime.legacy()}):up(); -- XEP-0091 (deprecated) + local entry = { stanza = stanza, stamp = stamp }; + t_insert(history, entry); + while #history > (self._data.history_length or default_history_length) do t_remove(history, 1) end + end +end +function room_mt:broadcast_except_nick(stanza, nick) + for rnick, occupant in pairs(self._occupants) do + if rnick ~= nick then + for jid in pairs(occupant.sessions) do + stanza.attr.to = jid; + self:_route_stanza(stanza); + end + end + end +end + +function room_mt:send_occupant_list(to) + local current_nick = self._jid_nick[to]; + for occupant, o_data in pairs(self._occupants) do + if occupant ~= current_nick then + local pres = get_filtered_presence(o_data.sessions[o_data.jid]); + pres.attr.to, pres.attr.from = to, occupant; + pres:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) + :tag("item", {affiliation=o_data.affiliation or "none", role=o_data.role or "none"}):up(); + self:_route_stanza(pres); + end + end +end +function room_mt:send_history(to, stanza) + local history = self._data['history']; -- send discussion history + if history then + local x_tag = stanza and stanza:get_child("x", "http://jabber.org/protocol/muc"); + local history_tag = x_tag and x_tag:get_child("history", "http://jabber.org/protocol/muc"); + + local maxchars = history_tag and tonumber(history_tag.attr.maxchars); + if maxchars then maxchars = math.floor(maxchars); end + + local maxstanzas = math.floor(history_tag and tonumber(history_tag.attr.maxstanzas) or #history); + if not history_tag then maxstanzas = 20; end + + local seconds = history_tag and tonumber(history_tag.attr.seconds); + if seconds then seconds = datetime.datetime(os.time() - math.floor(seconds)); end + + local since = history_tag and history_tag.attr.since; + if since then since = datetime.parse(since); since = since and datetime.datetime(since); end + if seconds and (not since or since < seconds) then since = seconds; end + + local n = 0; + local charcount = 0; + + for i=#history,1,-1 do + local entry = history[i]; + if maxchars then + if not entry.chars then + entry.stanza.attr.to = ""; + entry.chars = #tostring(entry.stanza); + end + charcount = charcount + entry.chars + #to; + if charcount > maxchars then break; end + end + if since and since > entry.stamp then break; end + if n + 1 > maxstanzas then break; end + n = n + 1; + end + for i=#history-n+1,#history do + local msg = history[i].stanza; + msg.attr.to = to; + self:_route_stanza(msg); + end + end + if self._data['subject'] then + self:_route_stanza(st.message({type='groupchat', from=self._data['subject_from'] or self.jid, to=to}):tag("subject"):text(self._data['subject'])); + end +end + +function room_mt:get_disco_info(stanza) + local count = 0; for _ in pairs(self._occupants) do count = count + 1; end + return st.reply(stanza):query("http://jabber.org/protocol/disco#info") + :tag("identity", {category="conference", type="text", name=self:get_name()}):up() + :tag("feature", {var="http://jabber.org/protocol/muc"}):up() + :tag("feature", {var=self:get_password() and "muc_passwordprotected" or "muc_unsecured"}):up() + :tag("feature", {var=self:is_moderated() and "muc_moderated" or "muc_unmoderated"}):up() + :tag("feature", {var=self:is_members_only() and "muc_membersonly" or "muc_open"}):up() + :tag("feature", {var=self:is_persistent() and "muc_persistent" or "muc_temporary"}):up() + :tag("feature", {var=self:is_hidden() and "muc_hidden" or "muc_public"}):up() + :tag("feature", {var=self._data.whois ~= "anyone" and "muc_semianonymous" or "muc_nonanonymous"}):up() + :add_child(dataform.new({ + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#roominfo" }, + { name = "muc#roominfo_description", label = "Description"}, + { name = "muc#roominfo_occupants", label = "Number of occupants", value = tostring(count) } + }):form({["muc#roominfo_description"] = self:get_description()}, 'result')) + ; +end +function room_mt:get_disco_items(stanza) + local reply = st.reply(stanza):query("http://jabber.org/protocol/disco#items"); + for room_jid in pairs(self._occupants) do + reply:tag("item", {jid = room_jid, name = room_jid:match("/(.*)")}):up(); + end + return reply; +end +function room_mt:set_subject(current_nick, subject) + -- TODO check nick's authority + if subject == "" then subject = nil; end + self._data['subject'] = subject; + self._data['subject_from'] = current_nick; + if self.save then self:save(); end + local msg = st.message({type='groupchat', from=current_nick}) + :tag('subject'):text(subject):up(); + self:broadcast_message(msg, false); + return true; +end + +local function build_unavailable_presence_from_error(stanza) + local type, condition, text = stanza:get_error(); + local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error"); + if text then + error_message = error_message..": "..text; + end + return st.presence({type='unavailable', from=stanza.attr.from, to=stanza.attr.to}) + :tag('status'):text(error_message); +end + +function room_mt:set_name(name) + if name == "" or type(name) ~= "string" or name == (jid_split(self.jid)) then name = nil; end + if self._data.name ~= name then + self._data.name = name; + if self.save then self:save(true); end + end +end +function room_mt:get_name() + return self._data.name or jid_split(self.jid); +end +function room_mt:set_description(description) + if description == "" or type(description) ~= "string" then description = nil; end + if self._data.description ~= description then + self._data.description = description; + if self.save then self:save(true); end + end +end +function room_mt:get_description() + return self._data.description; +end +function room_mt:set_password(password) + if password == "" or type(password) ~= "string" then password = nil; end + if self._data.password ~= password then + self._data.password = password; + if self.save then self:save(true); end + end +end +function room_mt:get_password() + return self._data.password; +end +function room_mt:set_moderated(moderated) + moderated = moderated and true or nil; + if self._data.moderated ~= moderated then + self._data.moderated = moderated; + if self.save then self:save(true); end + end +end +function room_mt:is_moderated() + return self._data.moderated; +end +function room_mt:set_members_only(members_only) + members_only = members_only and true or nil; + if self._data.members_only ~= members_only then + self._data.members_only = members_only; + if self.save then self:save(true); end + end +end +function room_mt:is_members_only() + return self._data.members_only; +end +function room_mt:set_persistent(persistent) + persistent = persistent and true or nil; + if self._data.persistent ~= persistent then + self._data.persistent = persistent; + if self.save then self:save(true); end + end +end +function room_mt:is_persistent() + return self._data.persistent; +end +function room_mt:set_hidden(hidden) + hidden = hidden and true or nil; + if self._data.hidden ~= hidden then + self._data.hidden = hidden; + if self.save then self:save(true); end + end +end +function room_mt:is_hidden() + return self._data.hidden; +end +function room_mt:set_changesubject(changesubject) + changesubject = changesubject and true or nil; + if self._data.changesubject ~= changesubject then + self._data.changesubject = changesubject; + if self.save then self:save(true); end + end +end +function room_mt:get_changesubject() + return self._data.changesubject; +end +function room_mt:get_historylength() + return self._data.history_length or default_history_length; +end +function room_mt:set_historylength(length) + length = math.min(tonumber(length) or default_history_length, max_history_length or math.huge); + if length == default_history_length then + length = nil; + end + self._data.history_length = length; +end + + +local function construct_stanza_id(room, stanza) + local from_jid, to_nick = stanza.attr.from, stanza.attr.to; + local from_nick = room._jid_nick[from_jid]; + local occupant = room._occupants[to_nick]; + local to_jid = occupant.jid; + + return from_nick, to_jid, base64.encode(to_jid.."\0"..stanza.attr.id.."\0"..md5(from_jid)); +end +local function deconstruct_stanza_id(room, stanza) + local from_jid_possiblybare, to_nick = stanza.attr.from, stanza.attr.to; + local from_jid, id, to_jid_hash = (base64.decode(stanza.attr.id) or ""):match("^(.+)%z(.*)%z(.+)$"); + local from_nick = room._jid_nick[from_jid]; + + if not(from_nick) then return; end + if not(from_jid_possiblybare == from_jid or from_jid_possiblybare == jid_bare(from_jid)) then return; end + + local occupant = room._occupants[to_nick]; + for to_jid in pairs(occupant and occupant.sessions or {}) do + if md5(to_jid) == to_jid_hash then + return from_nick, to_jid, id; + end + end +end + + +function room_mt:handle_to_occupant(origin, stanza) -- PM, vCards, etc + local from, to = stanza.attr.from, stanza.attr.to; + local room = jid_bare(to); + local current_nick = self._jid_nick[from]; + local type = stanza.attr.type; + log("debug", "room: %s, current_nick: %s, stanza: %s", room or "nil", current_nick or "nil", stanza:top_tag()); + if (select(2, jid_split(from)) == muc_domain) then error("Presence from the MUC itself!!!"); end + if stanza.name == "presence" then + local pr = get_filtered_presence(stanza); + pr.attr.from = current_nick; + if type == "error" then -- error, kick em out! + if current_nick then + log("debug", "kicking %s from %s", current_nick, room); + self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); + end + elseif type == "unavailable" then -- unavailable + if current_nick then + log("debug", "%s leaving %s", current_nick, room); + self._jid_nick[from] = nil; + local occupant = self._occupants[current_nick]; + local new_jid = next(occupant.sessions); + if new_jid == from then new_jid = next(occupant.sessions, new_jid); end + if new_jid then + local jid = occupant.jid; + occupant.jid = new_jid; + occupant.sessions[from] = nil; + pr.attr.to = from; + pr:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) + :tag("item", {affiliation=occupant.affiliation or "none", role='none'}):up() + :tag("status", {code='110'}):up(); + self:_route_stanza(pr); + if jid ~= new_jid then + pr = st.clone(occupant.sessions[new_jid]) + :tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) + :tag("item", {affiliation=occupant.affiliation or "none", role=occupant.role or "none"}); + pr.attr.from = current_nick; + self:broadcast_except_nick(pr, current_nick); + end + else + occupant.role = 'none'; + self:broadcast_presence(pr, from); + self._occupants[current_nick] = nil; + end + end + elseif not type then -- available + if current_nick then + --if #pr == #stanza or current_nick ~= to then -- commented because google keeps resending directed presence + if current_nick == to then -- simple presence + log("debug", "%s broadcasted presence", current_nick); + self._occupants[current_nick].sessions[from] = pr; + self:broadcast_presence(pr, from); + else -- change nick + local occupant = self._occupants[current_nick]; + local is_multisession = next(occupant.sessions, next(occupant.sessions)); + if self._occupants[to] or is_multisession then + log("debug", "%s couldn't change nick", current_nick); + local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + reply.tags[1].attr.code = "409"; + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + else + local data = self._occupants[current_nick]; + local to_nick = select(3, jid_split(to)); + if to_nick then + log("debug", "%s (%s) changing nick to %s", current_nick, data.jid, to); + local p = st.presence({type='unavailable', from=current_nick}); + self:broadcast_presence(p, from, '303', to_nick); + self._occupants[current_nick] = nil; + self._occupants[to] = data; + self._jid_nick[from] = to; + pr.attr.from = to; + self._occupants[to].sessions[from] = pr; + self:broadcast_presence(pr, from); + else + --TODO malformed-jid + end + end + end + --else -- possible rejoin + -- log("debug", "%s had connection replaced", current_nick); + -- self:handle_to_occupant(origin, st.presence({type='unavailable', from=from, to=to}) + -- :tag('status'):text('Replaced by new connection'):up()); -- send unavailable + -- self:handle_to_occupant(origin, stanza); -- resend available + --end + else -- enter room + local new_nick = to; + local is_merge; + if self._occupants[to] then + if jid_bare(from) ~= jid_bare(self._occupants[to].jid) then + new_nick = nil; + end + is_merge = true; + end + local password = stanza:get_child("x", "http://jabber.org/protocol/muc"); + password = password and password:get_child("password", "http://jabber.org/protocol/muc"); + password = password and password[1] ~= "" and password[1]; + if self:get_password() and self:get_password() ~= password then + log("debug", "%s couldn't join due to invalid password: %s", from, to); + local reply = st.error_reply(stanza, "auth", "not-authorized"):up(); + reply.tags[1].attr.code = "401"; + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + elseif not new_nick then + log("debug", "%s couldn't join due to nick conflict: %s", from, to); + local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + reply.tags[1].attr.code = "409"; + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + else + log("debug", "%s joining as %s", from, to); + if not next(self._affiliations) then -- new room, no owners + self._affiliations[jid_bare(from)] = "owner"; + end + local affiliation = self:get_affiliation(from); + local role = self:get_default_role(affiliation) + if role then -- new occupant + if not is_merge then + self._occupants[to] = {affiliation=affiliation, role=role, jid=from, sessions={[from]=get_filtered_presence(stanza)}}; + else + self._occupants[to].sessions[from] = get_filtered_presence(stanza); + end + self._jid_nick[from] = to; + self:send_occupant_list(from); + pr.attr.from = to; + pr:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) + :tag("item", {affiliation=affiliation or "none", role=role or "none"}):up(); + if not is_merge then + self:broadcast_except_nick(pr, to); + end + pr:tag("status", {code='110'}):up(); + if self._data.whois == 'anyone' then + pr:tag("status", {code='100'}):up(); + end + pr.attr.to = from; + self:_route_stanza(pr); + self:send_history(from, stanza); + elseif not affiliation then -- registration required for entering members-only room + local reply = st.error_reply(stanza, "auth", "registration-required"):up(); + reply.tags[1].attr.code = "407"; + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + else -- banned + local reply = st.error_reply(stanza, "auth", "forbidden"):up(); + reply.tags[1].attr.code = "403"; + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + end + end + end + 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? + end + end + elseif not current_nick then -- not in room + if (type == "error" or type == "result") and stanza.name == "iq" then + local id = stanza.attr.id; + stanza.attr.from, stanza.attr.to, stanza.attr.id = deconstruct_stanza_id(self, stanza); + if stanza.attr.id then + self:_route_stanza(stanza); + end + stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; + elseif type ~= "error" then + origin.send(st.error_reply(stanza, "cancel", "not-acceptable")); + end + elseif stanza.name == "message" and type == "groupchat" then -- groupchat messages not allowed in PM + origin.send(st.error_reply(stanza, "modify", "bad-request")); + elseif current_nick and stanza.name == "message" and type == "error" and is_kickable_error(stanza) then + log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid); + self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); -- send unavailable + else -- private stanza + local o_data = self._occupants[to]; + if o_data then + log("debug", "%s sent private stanza to %s (%s)", from, to, o_data.jid); + if stanza.name == "iq" then + local id = stanza.attr.id; + if stanza.attr.type == "get" or stanza.attr.type == "set" then + stanza.attr.from, stanza.attr.to, stanza.attr.id = construct_stanza_id(self, stanza); + else + stanza.attr.from, stanza.attr.to, stanza.attr.id = deconstruct_stanza_id(self, stanza); + end + if type == 'get' and stanza.tags[1].attr.xmlns == 'vcard-temp' then + stanza.attr.to = jid_bare(stanza.attr.to); + end + if stanza.attr.id then + self:_route_stanza(stanza); + end + stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; + else -- message + stanza.attr.from = current_nick; + for jid in pairs(o_data.sessions) do + stanza.attr.to = jid; + self:_route_stanza(stanza); + end + stanza.attr.from, stanza.attr.to = from, to; + end + elseif type ~= "error" and type ~= "result" then -- recipient not in room + origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room")); + end + end +end + +function room_mt:send_form(origin, stanza) + origin.send(st.reply(stanza):query("http://jabber.org/protocol/muc#owner") + :add_child(self:get_form_layout():form()) + ); +end + +function room_mt:get_form_layout() + local form = dataform.new({ + title = "Configuration for "..self.jid, + instructions = "Complete and submit this form to configure the room.", + { + name = 'FORM_TYPE', + type = 'hidden', + value = 'http://jabber.org/protocol/muc#roomconfig' + }, + { + name = 'muc#roomconfig_roomname', + type = 'text-single', + label = 'Name', + value = self:get_name() or "", + }, + { + name = 'muc#roomconfig_roomdesc', + type = 'text-single', + label = 'Description', + value = self:get_description() or "", + }, + { + name = 'muc#roomconfig_persistentroom', + type = 'boolean', + label = 'Make Room Persistent?', + value = self:is_persistent() + }, + { + name = 'muc#roomconfig_publicroom', + type = 'boolean', + label = 'Make Room Publicly Searchable?', + value = not self:is_hidden() + }, + { + name = 'muc#roomconfig_changesubject', + type = 'boolean', + label = 'Allow Occupants to Change Subject?', + value = self:get_changesubject() + }, + { + name = 'muc#roomconfig_whois', + type = 'list-single', + label = 'Who May Discover Real JIDs?', + value = { + { value = 'moderators', label = 'Moderators Only', default = self._data.whois == 'moderators' }, + { value = 'anyone', label = 'Anyone', default = self._data.whois == 'anyone' } + } + }, + { + name = 'muc#roomconfig_roomsecret', + type = 'text-private', + label = 'Password', + value = self:get_password() or "", + }, + { + name = 'muc#roomconfig_moderatedroom', + type = 'boolean', + label = 'Make Room Moderated?', + value = self:is_moderated() + }, + { + name = 'muc#roomconfig_membersonly', + type = 'boolean', + label = 'Make Room Members-Only?', + value = self:is_members_only() + }, + { + name = 'muc#roomconfig_historylength', + type = 'text-single', + label = 'Maximum Number of History Messages Returned by Room', + value = tostring(self:get_historylength()) + } + }); + return module:fire_event("muc-config-form", { room = self, form = form }) or form; +end + +local valid_whois = { + moderators = true, + anyone = true, +} + +function room_mt:process_form(origin, stanza) + local query = stanza.tags[1]; + local form; + for _, tag in ipairs(query.tags) do if tag.name == "x" and tag.attr.xmlns == "jabber:x:data" then form = tag; break; end end + if not form then origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); return; end + if form.attr.type == "cancel" then origin.send(st.reply(stanza)); return; end + if form.attr.type ~= "submit" then origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); return; end + + local fields = self:get_form_layout():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")); return; end + + local dirty = false + + local event = { room = self, fields = fields, changed = dirty }; + module:fire_event("muc-config-submitted", event); + dirty = event.changed or dirty; + + local name = fields['muc#roomconfig_roomname']; + if name ~= self:get_name() then + self:set_name(name); + end + + local description = fields['muc#roomconfig_roomdesc']; + if description ~= self:get_description() then + self:set_description(description); + end + + local persistent = fields['muc#roomconfig_persistentroom']; + dirty = dirty or (self:is_persistent() ~= persistent) + module:log("debug", "persistent=%s", tostring(persistent)); + + local moderated = fields['muc#roomconfig_moderatedroom']; + dirty = dirty or (self:is_moderated() ~= moderated) + module:log("debug", "moderated=%s", tostring(moderated)); + + local membersonly = fields['muc#roomconfig_membersonly']; + dirty = dirty or (self:is_members_only() ~= membersonly) + module:log("debug", "membersonly=%s", tostring(membersonly)); + + local public = fields['muc#roomconfig_publicroom']; + dirty = dirty or (self:is_hidden() ~= (not public and true or nil)) + + local changesubject = fields['muc#roomconfig_changesubject']; + dirty = dirty or (self:get_changesubject() ~= (not changesubject and true or nil)) + module:log('debug', 'changesubject=%s', changesubject and "true" or "false") + + local historylength = tonumber(fields['muc#roomconfig_historylength']); + dirty = dirty or (historylength and (self:get_historylength() ~= historylength)); + module:log('debug', 'historylength=%s', historylength) + + + local whois = fields['muc#roomconfig_whois']; + if not valid_whois[whois] then + origin.send(st.error_reply(stanza, 'cancel', 'bad-request', "Invalid value for 'whois'")); + return; + end + local whois_changed = self._data.whois ~= whois + self._data.whois = whois + module:log('debug', 'whois=%s', whois) + + local password = fields['muc#roomconfig_roomsecret']; + if self:get_password() ~= password then + self:set_password(password); + end + self:set_moderated(moderated); + self:set_members_only(membersonly); + self:set_persistent(persistent); + self:set_hidden(not public); + self:set_changesubject(changesubject); + self:set_historylength(historylength); + + if self.save then self:save(true); end + origin.send(st.reply(stanza)); + + if dirty or whois_changed then + local msg = st.message({type='groupchat', from=self.jid}) + :tag('x', {xmlns='http://jabber.org/protocol/muc#user'}):up() + + if dirty then + msg.tags[1]:tag('status', {code = '104'}):up(); + end + if whois_changed then + local code = (whois == 'moderators') and "173" or "172"; + msg.tags[1]:tag('status', {code = code}):up(); + end + + self:broadcast_message(msg, false) + end +end + +function room_mt:destroy(newjid, reason, password) + local pr = st.presence({type = "unavailable"}) + :tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("item", { affiliation='none', role='none' }):up() + :tag("destroy", {jid=newjid}) + if reason then pr:tag("reason"):text(reason):up(); end + if password then pr:tag("password"):text(password):up(); end + for nick, occupant in pairs(self._occupants) do + pr.attr.from = nick; + for jid in pairs(occupant.sessions) do + pr.attr.to = jid; + self:_route_stanza(pr); + self._jid_nick[jid] = nil; + end + self._occupants[nick] = nil; + end + self:set_persistent(false); +end + +function room_mt:handle_to_room(origin, stanza) -- presence changes and groupchat messages, along with disco/etc + local type = stanza.attr.type; + local xmlns = stanza.tags[1] and stanza.tags[1].attr.xmlns; + if stanza.name == "iq" then + if xmlns == "http://jabber.org/protocol/disco#info" and type == "get" and not stanza.tags[1].attr.node then + origin.send(self:get_disco_info(stanza)); + elseif xmlns == "http://jabber.org/protocol/disco#items" and type == "get" and not stanza.tags[1].attr.node then + origin.send(self:get_disco_items(stanza)); + elseif xmlns == "http://jabber.org/protocol/muc#admin" then + local actor = stanza.attr.from; + local affiliation = self:get_affiliation(actor); + local current_nick = self._jid_nick[actor]; + local role = current_nick and self._occupants[current_nick].role or self:get_default_role(affiliation); + local item = stanza.tags[1].tags[1]; + if item and item.name == "item" then + if type == "set" then + local callback = function() origin.send(st.reply(stanza)); end + if item.attr.jid then -- Validate provided JID + item.attr.jid = jid_prep(item.attr.jid); + if not item.attr.jid then + origin.send(st.error_reply(stanza, "modify", "jid-malformed")); + return; + end + end + if not item.attr.jid and item.attr.nick then -- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation + local occupant = self._occupants[self.jid.."/"..item.attr.nick]; + if occupant then item.attr.jid = occupant.jid; end + elseif not item.attr.nick and item.attr.jid then + local nick = self._jid_nick[item.attr.jid]; + if nick then item.attr.nick = select(3, jid_split(nick)); end + end + local reason = item.tags[1] and item.tags[1].name == "reason" and #item.tags[1] == 1 and item.tags[1][1]; + if item.attr.affiliation and item.attr.jid and not item.attr.role then + local success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, callback, reason); + if not success then origin.send(st.error_reply(stanza, errtype, err)); end + elseif item.attr.role and item.attr.nick and not item.attr.affiliation then + local success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, callback, reason); + if not success then origin.send(st.error_reply(stanza, errtype, err)); end + else + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + elseif type == "get" then + local _aff = item.attr.affiliation; + local _rol = item.attr.role; + if _aff and not _rol then + if affiliation == "owner" or (affiliation == "admin" and _aff ~= "owner" and _aff ~= "admin") then + local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); + for jid, affiliation in pairs(self._affiliations) do + if affiliation == _aff then + reply:tag("item", {affiliation = _aff, jid = jid}):up(); + end + end + origin.send(reply); + else + origin.send(st.error_reply(stanza, "auth", "forbidden")); + end + elseif _rol and not _aff then + if role == "moderator" then + -- TODO allow admins and owners not in room? Provide read-only access to everyone who can see the participants anyway? + if _rol == "none" then _rol = nil; end + local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); + for occupant_jid, occupant in pairs(self._occupants) do + if occupant.role == _rol then + reply:tag("item", { + nick = select(3, jid_split(occupant_jid)), + role = _rol or "none", + affiliation = occupant.affiliation or "none", + jid = occupant.jid + }):up(); + end + end + origin.send(reply); + else + origin.send(st.error_reply(stanza, "auth", "forbidden")); + end + else + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + end + elseif type == "set" or type == "get" then + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + elseif xmlns == "http://jabber.org/protocol/muc#owner" and (type == "get" or type == "set") and stanza.tags[1].name == "query" then + if self:get_affiliation(stanza.attr.from) ~= "owner" then + origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms")); + elseif stanza.attr.type == "get" then + self:send_form(origin, stanza); + elseif stanza.attr.type == "set" then + local child = stanza.tags[1].tags[1]; + if not child then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + elseif child.name == "destroy" then + local newjid = child.attr.jid; + local reason, password; + for _,tag in ipairs(child.tags) do + if tag.name == "reason" then + reason = #tag.tags == 0 and tag[1]; + elseif tag.name == "password" then + password = #tag.tags == 0 and tag[1]; + end + end + self:destroy(newjid, reason, password); + origin.send(st.reply(stanza)); + else + self:process_form(origin, stanza); + end + end + elseif type == "set" or type == "get" then + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + end + elseif stanza.name == "message" and type == "groupchat" then + local from, to = stanza.attr.from, stanza.attr.to; + local current_nick = self._jid_nick[from]; + local occupant = self._occupants[current_nick]; + if not occupant then -- not in room + origin.send(st.error_reply(stanza, "cancel", "not-acceptable")); + elseif occupant.role == "visitor" then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + else + local from = stanza.attr.from; + stanza.attr.from = current_nick; + local subject = getText(stanza, {"subject"}); + if subject then + if occupant.role == "moderator" or + ( self._data.changesubject and occupant.role == "participant" ) then -- and participant + self:set_subject(current_nick, subject); -- TODO use broadcast_message_stanza + else + stanza.attr.from = from; + origin.send(st.error_reply(stanza, "auth", "forbidden")); + end + else + self:broadcast_message(stanza, self:get_historylength() > 0 and stanza:get_child("body")); + end + stanza.attr.from = from; + end + elseif stanza.name == "message" and type == "error" and is_kickable_error(stanza) then + local current_nick = self._jid_nick[stanza.attr.from]; + log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid); + self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); -- send unavailable + elseif stanza.name == "presence" then -- hack - some buggy clients send presence updates to the room rather than their nick + local to = stanza.attr.to; + local current_nick = self._jid_nick[stanza.attr.from]; + if current_nick then + stanza.attr.to = current_nick; + self:handle_to_occupant(origin, stanza); + stanza.attr.to = to; + elseif type ~= "error" and type ~= "result" then + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + end + elseif stanza.name == "message" and not(type == "chat" or type == "error" or type == "groupchat" or type == "headline") and #stanza.tags == 1 + and self._jid_nick[stanza.attr.from] and stanza.tags[1].name == "x" and stanza.tags[1].attr.xmlns == "http://jabber.org/protocol/muc#user" then + local x = stanza.tags[1]; + local payload = (#x.tags == 1 and x.tags[1]); + if payload and payload.name == "invite" and payload.attr.to then + local _from, _to = stanza.attr.from, stanza.attr.to; + local _invitee = jid_prep(payload.attr.to); + if _invitee then + local _reason = payload.tags[1] and payload.tags[1].name == 'reason' and #payload.tags[1].tags == 0 and payload.tags[1][1]; + local invite = st.message({from = _to, to = _invitee, id = stanza.attr.id}) + :tag('x', {xmlns='http://jabber.org/protocol/muc#user'}) + :tag('invite', {from=_from}) + :tag('reason'):text(_reason or ""):up() + :up(); + if self:get_password() then + invite:tag("password"):text(self:get_password()):up(); + end + invite:up() + :tag('x', {xmlns="jabber:x:conference", jid=_to}) -- COMPAT: Some older clients expect this + :text(_reason or "") + :up() + :tag('body') -- Add a plain message for clients which don't support invites + :text(_from..' invited you to the room '.._to..(_reason and (' ('.._reason..')') or "")) + :up(); + if self:is_members_only() and not self:get_affiliation(_invitee) then + log("debug", "%s invited %s into members only room %s, granting membership", _from, _invitee, _to); + self:set_affiliation(_from, _invitee, "member", nil, "Invited by " .. self._jid_nick[_from]) + end + self:_route_stanza(invite); + else + origin.send(st.error_reply(stanza, "cancel", "jid-malformed")); + end + else + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + else + if type == "error" or type == "result" then return; end + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + end +end + +function room_mt:handle_stanza(origin, stanza) + local to_node, to_host, to_resource = jid_split(stanza.attr.to); + if to_resource then + self:handle_to_occupant(origin, stanza); + else + self:handle_to_room(origin, stanza); + end +end + +function room_mt:route_stanza(stanza) end -- Replace with a routing function, e.g., function(room, stanza) core_route_stanza(origin, stanza); end + +function room_mt:get_affiliation(jid) + local node, host, resource = jid_split(jid); + local bare = node and node.."@"..host or host; + local result = self._affiliations[bare]; -- Affiliations are granted, revoked, and maintained based on the user's bare JID. + if not result and self._affiliations[host] == "outcast" then result = "outcast"; end -- host banned + return result; +end +function room_mt:set_affiliation(actor, jid, affiliation, callback, reason) + jid = jid_bare(jid); + if affiliation == "none" then affiliation = nil; end + if affiliation and affiliation ~= "outcast" and affiliation ~= "owner" and affiliation ~= "admin" and affiliation ~= "member" then + return nil, "modify", "not-acceptable"; + end + if actor ~= true then + local actor_affiliation = self:get_affiliation(actor); + local target_affiliation = self:get_affiliation(jid); + if target_affiliation == affiliation then -- no change, shortcut + if callback then callback(); end + return true; + end + if actor_affiliation ~= "owner" then + if affiliation == "owner" or affiliation == "admin" or actor_affiliation ~= "admin" or target_affiliation == "owner" or target_affiliation == "admin" then + return nil, "cancel", "not-allowed"; + end + elseif target_affiliation == "owner" and jid_bare(actor) == jid then -- self change + local is_last = true; + for j, aff in pairs(self._affiliations) do if j ~= jid and aff == "owner" then is_last = false; break; end end + if is_last then + return nil, "cancel", "conflict"; + end + end + end + self._affiliations[jid] = affiliation; + local role = self:get_default_role(affiliation); + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("item", {affiliation=affiliation or "none", role=role or "none"}) + :tag("reason"):text(reason or ""):up() + :up(); + local presence_type = nil; + if not role then -- getting kicked + presence_type = "unavailable"; + if affiliation == "outcast" then + x:tag("status", {code="301"}):up(); -- banned + else + x:tag("status", {code="321"}):up(); -- affiliation change + end + end + local modified_nicks = {}; + for nick, occupant in pairs(self._occupants) do + if jid_bare(occupant.jid) == jid then + if not role then -- getting kicked + self._occupants[nick] = nil; + else + occupant.affiliation, occupant.role = affiliation, role; + end + for jid,pres in pairs(occupant.sessions) do -- remove for all sessions of the nick + if not role then self._jid_nick[jid] = nil; end + local p = st.clone(pres); + p.attr.from = nick; + p.attr.type = presence_type; + p.attr.to = jid; + p:add_child(x); + self:_route_stanza(p); + if occupant.jid == jid then + modified_nicks[nick] = p; + end + end + end + end + if self.save then self:save(); end + if callback then callback(); end + for nick,p in pairs(modified_nicks) do + p.attr.from = nick; + self:broadcast_except_nick(p, nick); + end + return true; +end + +function room_mt:get_role(nick) + local session = self._occupants[nick]; + return session and session.role or nil; +end +function room_mt:can_set_role(actor_jid, occupant_jid, role) + local occupant = self._occupants[occupant_jid]; + if not occupant or not actor then return nil, "modify", "not-acceptable"; end + + if actor_jid == true then return true; end + + local actor = self._occupants[self._jid_nick[actor_jid]]; + if actor.role == "moderator" then + if occupant.affiliation ~= "owner" and occupant.affiliation ~= "admin" then + if actor.affiliation == "owner" or actor.affiliation == "admin" then + return true; + elseif occupant.role ~= "moderator" and role ~= "moderator" then + return true; + end + end + end + return nil, "cancel", "not-allowed"; +end +function room_mt:set_role(actor, occupant_jid, role, callback, reason) + if role == "none" then role = nil; end + if role and role ~= "moderator" and role ~= "participant" and role ~= "visitor" then return nil, "modify", "not-acceptable"; end + local allowed, err_type, err_condition = self:can_set_role(actor, occupant_jid, role); + if not allowed then return allowed, err_type, err_condition; end + local occupant = self._occupants[occupant_jid]; + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("item", {affiliation=occupant.affiliation or "none", nick=select(3, jid_split(occupant_jid)), role=role or "none"}) + :tag("reason"):text(reason or ""):up() + :up(); + local presence_type = nil; + if not role then -- kick + presence_type = "unavailable"; + self._occupants[occupant_jid] = nil; + for jid in pairs(occupant.sessions) do -- remove for all sessions of the nick + self._jid_nick[jid] = nil; + end + x:tag("status", {code = "307"}):up(); + else + occupant.role = role; + end + local bp; + for jid,pres in pairs(occupant.sessions) do -- send to all sessions of the nick + local p = st.clone(pres); + p.attr.from = occupant_jid; + p.attr.type = presence_type; + p.attr.to = jid; + p:add_child(x); + self:_route_stanza(p); + if occupant.jid == jid then + bp = p; + end + end + if callback then callback(); end + if bp then + self:broadcast_except_nick(bp, occupant_jid); + end + return true; +end + +function room_mt:_route_stanza(stanza) + local muc_child; + local to_occupant = self._occupants[self._jid_nick[stanza.attr.to]]; + local from_occupant = self._occupants[stanza.attr.from]; + if stanza.name == "presence" then + if to_occupant and from_occupant then + if self._data.whois == 'anyone' then + muc_child = stanza:get_child("x", "http://jabber.org/protocol/muc#user"); + else + if to_occupant.role == "moderator" or jid_bare(to_occupant.jid) == jid_bare(from_occupant.jid) then + muc_child = stanza:get_child("x", "http://jabber.org/protocol/muc#user"); + end + end + end + end + if muc_child then + for _, item in pairs(muc_child.tags) do + if item.name == "item" then + if from_occupant == to_occupant then + item.attr.jid = stanza.attr.to; + else + item.attr.jid = from_occupant.jid; + end + end + end + end + self:route_stanza(stanza); + if muc_child then + for _, item in pairs(muc_child.tags) do + if item.name == "item" then + item.attr.jid = nil; + end + end + end +end + +local _M = {}; -- module "muc" + +function _M.new_room(jid, config) + return setmetatable({ + jid = jid; + _jid_nick = {}; + _occupants = {}; + _data = { + whois = 'moderators'; + history_length = math.min((config and config.history_length) + or default_history_length, max_history_length); + }; + _affiliations = {}; + }, room_mt); +end + +function _M.set_max_history_length(_max_history_length) + max_history_length = _max_history_length or math.huge; +end + +_M.room_mt = room_mt; + +return _M; diff --git a/plugins/sql.lib.lua b/plugins/sql.lib.lua new file mode 100644 index 00000000..005ee45d --- /dev/null +++ b/plugins/sql.lib.lua @@ -0,0 +1,9 @@ +local cache = module:shared("/*/sql.lib/util.sql"); + +if not cache._M then + prosody.unlock_globals(); + cache._M = require "util.sql"; + prosody.lock_globals(); +end + +return cache._M; diff --git a/plugins/storage/mod_xep0227.lua b/plugins/storage/mod_xep0227.lua new file mode 100644 index 00000000..5d07a2ea --- /dev/null +++ b/plugins/storage/mod_xep0227.lua @@ -0,0 +1,163 @@ + +local ipairs, pairs = ipairs, pairs; +local setmetatable = setmetatable; +local tostring = tostring; +local next = next; +local t_remove = table.remove; +local os_remove = os.remove; +local io_open = io.open; + +local st = require "util.stanza"; +local parse_xml_real = require "util.xml".parse; + +local function getXml(user, host) + local jid = user.."@"..host; + local path = "data/"..jid..".xml"; + local f = io_open(path); + if not f then return; end + local s = f:read("*a"); + return parse_xml_real(s); +end +local function setXml(user, host, xml) + local jid = user.."@"..host; + local path = "data/"..jid..".xml"; + if xml then + local f = io_open(path, "w"); + if not f then return; end + local s = tostring(xml); + f:write(s); + f:close(); + return true; + else + return os_remove(path); + end +end +local function getUserElement(xml) + if xml and xml.name == "server-data" then + local host = xml.tags[1]; + if host and host.name == "host" then + local user = host.tags[1]; + if user and user.name == "user" then + return user; + end + end + end +end +local function createOuterXml(user, host) + return st.stanza("server-data", {xmlns='http://www.xmpp.org/extensions/xep-0227.html#ns'}) + :tag("host", {jid=host}) + :tag("user", {name = user}); +end +local function removeFromArray(array, value) + for i,item in ipairs(array) do + if item == value then + t_remove(array, i); + return; + end + end +end +local function removeStanzaChild(s, child) + removeFromArray(s.tags, child); + removeFromArray(s, child); +end + +local handlers = {}; + +handlers.accounts = { + get = function(self, user) + local user = getUserElement(getXml(user, self.host)); + if user and user.attr.password then + return { password = user.attr.password }; + end + end; + set = function(self, user, data) + if data and data.password then + local xml = getXml(user, self.host); + if not xml then xml = createOuterXml(user, self.host); end + local usere = getUserElement(xml); + usere.attr.password = data.password; + return setXml(user, self.host, xml); + else + return setXml(user, self.host, nil); + end + end; +}; +handlers.vcard = { + get = function(self, user) + local user = getUserElement(getXml(user, self.host)); + if user then + local vcard = user:get_child("vCard", 'vcard-temp'); + if vcard then + return st.preserialize(vcard); + end + end + end; + set = function(self, user, data) + local xml = getXml(user, self.host); + local usere = xml and getUserElement(xml); + if usere then + local vcard = usere:get_child("vCard", 'vcard-temp'); + if vcard then + removeStanzaChild(usere, vcard); + elseif not data then + return true; + end + if data then + vcard = st.deserialize(data); + usere:add_child(vcard); + end + return setXml(user, self.host, xml); + end + return true; + end; +}; +handlers.private = { + get = function(self, user) + local user = getUserElement(getXml(user, self.host)); + if user then + local private = user:get_child("query", "jabber:iq:private"); + if private then + local r = {}; + for _, tag in ipairs(private.tags) do + r[tag.name..":"..tag.attr.xmlns] = st.preserialize(tag); + end + return r; + end + end + end; + set = function(self, user, data) + local xml = getXml(user, self.host); + local usere = xml and getUserElement(xml); + if usere then + local private = usere:get_child("query", 'jabber:iq:private'); + if private then removeStanzaChild(usere, private); end + if data and next(data) ~= nil then + private = st.stanza("query", {xmlns='jabber:iq:private'}); + for _,tag in pairs(data) do + private:add_child(st.deserialize(tag)); + end + usere:add_child(private); + end + return setXml(user, self.host, xml); + end + return true; + end; +}; + +----------------------------- +local driver = {}; + +function driver:open(host, datastore, typ) + local instance = setmetatable({}, self); + instance.host = host; + instance.datastore = datastore; + local handler = handlers[datastore]; + if not handler then return nil; end + for key,val in pairs(handler) do + instance[key] = val; + end + if instance.init then instance:init(); end + return instance; +end + +module:provides("storage", driver); diff --git a/plugins/storage/sqlbasic.lib.lua b/plugins/storage/sqlbasic.lib.lua new file mode 100644 index 00000000..ab3648f9 --- /dev/null +++ b/plugins/storage/sqlbasic.lib.lua @@ -0,0 +1,97 @@ + +-- Basic SQL driver +-- This driver stores data as simple key-values + +local ser = require "util.serialization".serialize; +local envload = require "util.envload".envload; +local deser = function(data) + module:log("debug", "deser: %s", tostring(data)); + if not data then return nil; end + local f = envload("return "..data, nil, {}); + if not f then return nil; end + local s, d = pcall(f); + if not s then return nil; end + return d; +end; + +local driver = {}; +driver.__index = driver; + +driver.item_table = "item"; +driver.list_table = "list"; + +function driver:prepare(sql) + module:log("debug", "query: %s", sql); + local err; + if not self.sqlcache then self.sqlcache = {}; end + local r = self.sqlcache[sql]; + if r then return r; end + r, err = self.connection:prepare(sql); + if not r then error("Unable to prepare SQL statement: "..err); end + self.sqlcache[sql] = r; + return r; +end + +function driver:load(username, host, datastore) + local select = self:prepare("select data from "..self.item_table.." where username=? and host=? and datastore=?"); + select:execute(username, host, datastore); + local row = select:fetch(); + return row and deser(row[1]) or nil; +end + +function driver:store(username, host, datastore, data) + if not data or next(data) == nil then + local delete = self:prepare("delete from "..self.item_table.." where username=? and host=? and datastore=?"); + delete:execute(username, host, datastore); + return true; + else + local d = self:load(username, host, datastore); + if d then -- update + local update = self:prepare("update "..self.item_table.." set data=? where username=? and host=? and datastore=?"); + return update:execute(ser(data), username, host, datastore); + else -- insert + local insert = self:prepare("insert into "..self.item_table.." values (?, ?, ?, ?)"); + return insert:execute(username, host, datastore, ser(data)); + end + end +end + +function driver:list_append(username, host, datastore, data) + if not data then return; end + local insert = self:prepare("insert into "..self.list_table.." values (?, ?, ?, ?)"); + return insert:execute(username, host, datastore, ser(data)); +end + +function driver:list_store(username, host, datastore, data) + -- remove existing data + local delete = self:prepare("delete from "..self.list_table.." where username=? and host=? and datastore=?"); + delete:execute(username, host, datastore); + if data and next(data) ~= nil then + -- add data + for _, d in ipairs(data) do + self:list_append(username, host, datastore, ser(d)); + end + end + return true; +end + +function driver:list_load(username, host, datastore) + local select = self:prepare("select data from "..self.list_table.." where username=? and host=? and datastore=?"); + select:execute(username, host, datastore); + local r = {}; + for row in select:rows() do + table.insert(r, deser(row[1])); + end + return r; +end + +local _M = {}; +function _M.new(dbtype, dbname, ...) + local d = {}; + setmetatable(d, driver); + local dbh = get_database(dbtype, dbname, ...); + --d:set_connection(dbh); + d.connection = dbh; + return d; +end +return _M; diff --git a/plugins/storage/xep227store.lib.lua b/plugins/storage/xep227store.lib.lua new file mode 100644 index 00000000..5ef8df54 --- /dev/null +++ b/plugins/storage/xep227store.lib.lua @@ -0,0 +1,168 @@ +
+local st = require "util.stanza";
+
+local function getXml(user, host)
+ local jid = user.."@"..host;
+ local path = "data/"..jid..".xml";
+ local f = io.open(path);
+ if not f then return; end
+ local s = f:read("*a");
+ return parse_xml_real(s);
+end
+local function setXml(user, host, xml)
+ local jid = user.."@"..host;
+ local path = "data/"..jid..".xml";
+ if xml then
+ local f = io.open(path, "w");
+ if not f then return; end
+ local s = tostring(xml);
+ f:write(s);
+ f:close();
+ return true;
+ else
+ return os.remove(path);
+ end
+end
+local function getUserElement(xml)
+ if xml and xml.name == "server-data" then
+ local host = xml.tags[1];
+ if host and host.name == "host" then
+ local user = host.tags[1];
+ if user and user.name == "user" then
+ return user;
+ end
+ end
+ end
+end
+local function createOuterXml(user, host)
+ return st.stanza("server-data", {xmlns='http://www.xmpp.org/extensions/xep-0227.html#ns'})
+ :tag("host", {jid=host})
+ :tag("user", {name = user});
+end
+local function removeFromArray(array, value)
+ for i,item in ipairs(array) do
+ if item == value then
+ table.remove(array, i);
+ return;
+ end
+ end
+end
+local function removeStanzaChild(s, child)
+ removeFromArray(s.tags, child);
+ removeFromArray(s, child);
+end
+
+local handlers = {};
+
+handlers.accounts = {
+ get = function(self, user)
+ local user = getUserElement(getXml(user, self.host));
+ if user and user.attr.password then
+ return { password = user.attr.password };
+ end
+ end;
+ set = function(self, user, data)
+ if data and data.password then
+ local xml = getXml(user, self.host);
+ if not xml then xml = createOuterXml(user, self.host); end
+ local usere = getUserElement(xml);
+ usere.attr.password = data.password;
+ return setXml(user, self.host, xml);
+ else
+ return setXml(user, self.host, nil);
+ end
+ end;
+};
+handlers.vcard = {
+ get = function(self, user)
+ local user = getUserElement(getXml(user, self.host));
+ if user then
+ local vcard = user:get_child("vCard", 'vcard-temp');
+ if vcard then
+ return st.preserialize(vcard);
+ end
+ end
+ end;
+ set = function(self, user, data)
+ local xml = getXml(user, self.host);
+ local usere = xml and getUserElement(xml);
+ if usere then
+ local vcard = usere:get_child("vCard", 'vcard-temp');
+ if vcard then
+ removeStanzaChild(usere, vcard);
+ elseif not data then
+ return true;
+ end
+ if data then
+ vcard = st.deserialize(data);
+ usere:add_child(vcard);
+ end
+ return setXml(user, self.host, xml);
+ end
+ return true;
+ end;
+};
+handlers.private = {
+ get = function(self, user)
+ local user = getUserElement(getXml(user, self.host));
+ if user then
+ local private = user:get_child("query", "jabber:iq:private");
+ if private then
+ local r = {};
+ for _, tag in ipairs(private.tags) do
+ r[tag.name..":"..tag.attr.xmlns] = st.preserialize(tag);
+ end
+ return r;
+ end
+ end
+ end;
+ set = function(self, user, data)
+ local xml = getXml(user, self.host);
+ local usere = xml and getUserElement(xml);
+ if usere then
+ local private = usere:get_child("query", 'jabber:iq:private');
+ if private then removeStanzaChild(usere, private); end
+ if data and next(data) ~= nil then
+ private = st.stanza("query", {xmlns='jabber:iq:private'});
+ for _,tag in pairs(data) do
+ private:add_child(st.deserialize(tag));
+ end
+ usere:add_child(private);
+ end
+ return setXml(user, self.host, xml);
+ end
+ return true;
+ end;
+};
+
+-----------------------------
+local driver = {};
+driver.__index = driver;
+
+function driver:open(host, datastore, typ)
+ local cache_key = host.." "..datastore;
+ if self.ds_cache[cache_key] then return self.ds_cache[cache_key]; end
+ local instance = setmetatable({}, self);
+ instance.host = host;
+ instance.datastore = datastore;
+ local handler = handlers[datastore];
+ if not handler then return nil; end
+ for key,val in pairs(handler) do
+ instance[key] = val;
+ end
+ if instance.init then instance:init(); end
+ self.ds_cache[cache_key] = instance;
+ return instance;
+end
+
+-----------------------------
+local _M = {};
+
+function _M.new()
+ local instance = setmetatable({}, driver);
+ instance.__index = instance;
+ instance.ds_cache = {};
+ return instance;
+end
+
+return _M;
|