diff options
Diffstat (limited to 'plugins/mod_admin_shell.lua')
-rw-r--r-- | plugins/mod_admin_shell.lua | 1245 |
1 files changed, 957 insertions, 288 deletions
diff --git a/plugins/mod_admin_shell.lua b/plugins/mod_admin_shell.lua index f2da286b..e6b44f00 100644 --- a/plugins/mod_admin_shell.lua +++ b/plugins/mod_admin_shell.lua @@ -10,38 +10,41 @@ module:set_global(); module:depends("admin_socket"); -local hostmanager = require "core.hostmanager"; -local modulemanager = require "core.modulemanager"; -local s2smanager = require "core.s2smanager"; -local portmanager = require "core.portmanager"; -local helpers = require "util.helpers"; -local server = require "net.server"; -local st = require "util.stanza"; +local hostmanager = require "prosody.core.hostmanager"; +local modulemanager = require "prosody.core.modulemanager"; +local s2smanager = require "prosody.core.s2smanager"; +local portmanager = require "prosody.core.portmanager"; +local helpers = require "prosody.util.helpers"; +local it = require "prosody.util.iterators"; +local server = require "prosody.net.server"; +local schema = require "prosody.util.jsonschema"; +local st = require "prosody.util.stanza"; local _G = _G; local prosody = _G.prosody; -local unpack = table.unpack or unpack; -- luacheck: ignore 113 -local iterators = require "util.iterators"; +local unpack = table.unpack; +local iterators = require "prosody.util.iterators"; local keys, values = iterators.keys, iterators.values; -local jid_bare, jid_split, jid_join, jid_compare = import("util.jid", "bare", "prepped_split", "join", "compare"); -local set, array = require "util.set", require "util.array"; -local cert_verify_identity = require "util.x509".verify_identity; -local envload = require "util.envload".envload; -local envloadfile = require "util.envload".envloadfile; -local has_pposix, pposix = pcall(require, "util.pposix"); -local async = require "util.async"; -local serialization = require "util.serialization"; +local jid_bare, jid_split, jid_join, jid_resource, jid_compare = import("prosody.util.jid", "bare", "prepped_split", "join", "resource", "compare"); +local set, array = require "prosody.util.set", require "prosody.util.array"; +local cert_verify_identity = require "prosody.util.x509".verify_identity; +local envload = require "prosody.util.envload".envload; +local envloadfile = require "prosody.util.envload".envloadfile; +local has_pposix, pposix = pcall(require, "prosody.util.pposix"); +local async = require "prosody.util.async"; +local serialization = require "prosody.util.serialization"; local serialize_config = serialization.new ({ fatal = false, unquoted = true}); -local time = require "util.time"; -local promise = require "util.promise"; +local time = require "prosody.util.time"; +local promise = require "prosody.util.promise"; +local logger = require "prosody.util.logger"; local t_insert = table.insert; local t_concat = table.concat; -local format_number = require "util.human.units".format; -local format_table = require "util.human.io".table; +local format_number = require "prosody.util.human.units".format; +local format_table = require "prosody.util.human.io".table; local function capitalize(s) if not s then return end @@ -62,6 +65,86 @@ local commands = module:shared("commands") local def_env = module:shared("env"); local default_env_mt = { __index = def_env }; +local function new_section(section_desc) + return setmetatable({}, { + help = { + desc = section_desc; + commands = {}; + }; + }); +end + +local help_topics = {}; +local function help_topic(name) + return function (desc) + return function (content) + help_topics[name] = { + desc = desc; + content = content; + }; + end; + end +end + +-- Seed with default sections and their description text +help_topic "console" "Help regarding the console itself" [[ +Hey! Welcome to Prosody's admin console. +First thing, if you're ever wondering how to get out, simply type 'quit'. +Secondly, note that we don't support the full telnet protocol yet (it's coming) +so you may have trouble using the arrow keys, etc. depending on your system. + +For now we offer a couple of handy shortcuts: +!! - Repeat the last command +!old!new! - repeat the last command, but with 'old' replaced by 'new' + +For those well-versed in Prosody's internals, or taking instruction from those who are, +you can prefix a command with > to escape the console sandbox, and access everything in +the running server. Great fun, but be careful not to break anything :) +]]; + +local available_columns; --forward declaration so it is reachable from the help + +help_topic "columns" "Information about customizing session listings" (function (self, print) + print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]] + print [['columns' argument as described here.]] + print [[]] + print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]] + print [[Available columns are:]] + local meta_columns = { + { title = "ID"; width = 5 }; + { title = "Column Title"; width = 12 }; + { title = "Description"; width = 12 }; + }; + -- auto-adjust widths + for column, spec in pairs(available_columns) do + meta_columns[1].width = math.max(meta_columns[1].width or 0, #column); + meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or "")); + meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or "")); + end + local row = format_table(meta_columns, self.session.width) + print(row()); + for column, spec in iterators.sorted_pairs(available_columns) do + print(row({ column, spec.title, spec.description })); + end + print [[]] + print [[Most fields on the internal session structures can also be used as columns]] + -- Also, you can pass a table column specification directly, with mapper callback and all +end); + +help_topic "roles" "Show information about user roles" [[ +Roles may grant access or restrict users from certain operations. + +Built-in roles are: + prosody:guest - Guest/anonymous user + prosody:registered - Registered user + prosody:member - Provisioned user + prosody:admin - Host administrator + prosody:operator - Server administrator + +Roles can be assigned using the user management commands (see 'help user'). +]]; + + local function redirect_output(target, session) local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end }); env.dofile = function(name) @@ -83,8 +166,8 @@ function runner_callbacks:error(err) self.data.print("Error: "..tostring(err)); end -local function send_repl_output(session, line) - return session.send(st.stanza("repl-output"):text(tostring(line))); +local function send_repl_output(session, line, attr) + return session.send(st.stanza("repl-output", attr):text(tostring(line))); end function console:new_session(admin_session) @@ -99,8 +182,14 @@ function console:new_session(admin_session) end return send_repl_output(admin_session, table.concat(t, "\t")); end; + write = function (t) + return send_repl_output(admin_session, t, { eol = "0" }); + end; serialize = tostring; disconnect = function () admin_session:close(); end; + is_connected = function () + return not not admin_session.conn; + end }; session.env = setmetatable({}, default_env_mt); @@ -126,6 +215,11 @@ local function handle_line(event) session = console:new_session(event.origin); event.origin.shell_session = session; end + + local default_width = 132; -- The common default of 80 is a bit too narrow for e.g. s2s:show(), 132 was another common width for hardware terminals + local margin = 2; -- To account for '| ' when lines are printed + session.width = (tonumber(event.stanza.attr.width) or default_width)-margin; + local line = event.stanza:get_text(); local useglobalenv; @@ -135,7 +229,7 @@ local function handle_line(event) line = line:gsub("^>", ""); useglobalenv = true; else - local command = line:match("^%w+") or line:match("%p"); + local command = line:match("^(%w+) ") or line:match("^%w+$") or line:match("%p"); if commands[command] then commands[command](session, line); event.origin.send(result); @@ -201,148 +295,50 @@ module:hook("admin/repl-input", function (event) return true; end); +local function describe_command(s) + local section, name, args, desc = s:match("^([%w_]+):([%w_]+)%(([^)]*)%) %- (.+)$"); + if not section then + error("Failed to parse command description: "..s); + end + local command_help = getmetatable(def_env[section]).help.commands; + command_help[name] = { + desc = desc; + args = array.collect(args:gmatch("[%w_]+")):map(function (arg_name) + return { name = arg_name }; + end); + }; +end + -- Console commands -- -- These are simple commands, not valid standalone in Lua -local available_columns; --forward declaration so it is reachable from the help - +-- Help about individual topics is handled by def_env.help function commands.help(session, data) local print = session.print; - local section = data:match("^help (%w+)"); - if not section then - print [[Commands are divided into multiple sections. For help on a particular section, ]] - print [[type: help SECTION (for example, 'help c2s'). Sections are: ]] - print [[]] - local row = format_table({ { title = "Section"; width = 7 }; { title = "Description"; width = "100%" } }) - print(row()) - print(row { "c2s"; "Commands to manage local client-to-server sessions" }) - print(row { "s2s"; "Commands to manage sessions between this server and others" }) - print(row { "http"; "Commands to inspect HTTP services" }) -- XXX plural but there is only one so far - print(row { "module"; "Commands to load/reload/unload modules/plugins" }) - print(row { "host"; "Commands to activate, deactivate and list virtual hosts" }) - print(row { "user"; "Commands to create and delete users, and change their passwords" }) - print(row { "roles"; "Show information about user roles" }) - print(row { "muc"; "Commands to create, list and manage chat rooms" }) - print(row { "stats"; "Commands to show internal statistics" }) - print(row { "server"; "Uptime, version, shutting down, etc." }) - print(row { "port"; "Commands to manage ports the server is listening on" }) - print(row { "dns"; "Commands to manage and inspect the internal DNS resolver" }) - print(row { "xmpp"; "Commands for sending XMPP stanzas" }) - print(row { "debug"; "Commands for debugging the server" }) - print(row { "config"; "Reloading the configuration, etc." }) - print(row { "columns"; "Information about customizing session listings" }) - print(row { "console"; "Help regarding the console itself" }) - elseif section == "c2s" then - print [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]] - print [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]] - print [[c2s:count() - Count sessions without listing them]] - print [[c2s:close(jid) - Close all sessions for the specified JID]] - print [[c2s:closeall() - Close all active c2s connections ]] - elseif section == "s2s" then - print [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]] - print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]] - print [[s2s:close(from, to) - Close a connection from one domain to another]] - print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]] - elseif section == "http" then - print [[http:list(hosts) - Show HTTP endpoints]] - elseif section == "module" then - print [[module:info(module, host) - Show information about a loaded module]] - print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]] - print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]] - print [[module:unload(module, host) - The same, but just unloads the module from memory]] - print [[module:list(host) - List the modules loaded on the specified host]] - elseif section == "host" then - print [[host:activate(hostname) - Activates the specified host]] - print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]] - print [[host:list() - List the currently-activated hosts]] - elseif section == "user" then - print [[user:create(jid, password, roles) - Create the specified user account]] - print [[user:password(jid, password) - Set the password for the specified user account]] - print [[user:roles(jid, host) - Show current roles for an user]] - print [[user:setroles(jid, host, roles) - Set roles for an user (see 'help roles')]] - print [[user:delete(jid) - Permanently remove the specified user account]] - print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]] - elseif section == "roles" then - print [[Roles may grant access or restrict users from certain operations]] - print [[Built-in roles are:]] - print [[ prosody:admin - Administrator]] - print [[ (empty set) - Normal user]] - print [[]] - print [[The canonical role format looks like: { ["example:role"] = true }]] - print [[For convenience, the following formats are also accepted:]] - print [["admin" - short for "prosody:admin", the normal admin status (like the admins config option)]] - print [["example:role" - short for {["example:role"]=true}]] - print [[{"example:role"} - short for {["example:role"]=true}]] - elseif section == "muc" then - -- TODO `muc:room():foo()` commands - print [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]] - print [[muc:list(host) - List rooms on the specified MUC component]] - print [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]] - elseif section == "server" then - print [[server:version() - Show the server's version number]] - print [[server:uptime() - Show how long the server has been running]] - print [[server:memory() - Show details about the server's memory usage]] - print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]] - elseif section == "port" then - print [[port:list() - Lists all network ports prosody currently listens on]] - print [[port:close(port, interface) - Close a port]] - elseif section == "dns" then - print [[dns:lookup(name, type, class) - Do a DNS lookup]] - print [[dns:addnameserver(nameserver) - Add a nameserver to the list]] - print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]] - print [[dns:purge() - Clear the DNS cache]] - print [[dns:cache() - Show cached records]] - elseif section == "xmpp" then - print [[xmpp:ping(localhost, remotehost) -- Sends a ping to a remote XMPP server and reports the response]] - elseif section == "config" then - print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]] - print [[config:get([host,] option) - Show the value of a config option.]] - elseif section == "stats" then -- luacheck: ignore 542 - print [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern]] - print [[stats:show():cfgraph() - Show a cumulative frequency graph]] - print [[stats:show():histogram() - Show a histogram of selected metric]] - elseif section == "debug" then - print [[debug:logevents(host) - Enable logging of fired events on host]] - print [[debug:events(host, event) - Show registered event handlers]] - print [[debug:timers() - Show information about scheduled timers]] - elseif section == "console" then - print [[Hey! Welcome to Prosody's admin console.]] - print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]] - print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]] - print [[so you may have trouble using the arrow keys, etc. depending on your system.]] - print [[]] - print [[For now we offer a couple of handy shortcuts:]] - print [[!! - Repeat the last command]] - print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']] - print [[]] - print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]] - print [[you can prefix a command with > to escape the console sandbox, and access everything in]] - print [[the running server. Great fun, but be careful not to break anything :)]] - elseif section == "columns" then - print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]] - print [['columns' argument as described here.]] - print [[]] - print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]] - print [[Available columns are:]] - local meta_columns = { - { title = "ID"; width = 5 }; - { title = "Column Title"; width = 12 }; - { title = "Description"; width = 12 }; - }; - -- auto-adjust widths - for column, spec in pairs(available_columns) do - meta_columns[1].width = math.max(meta_columns[1].width or 0, #column); - meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or "")); - meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or "")); - end - local row = format_table(meta_columns, 120) - print(row()); - for column, spec in iterators.sorted_pairs(available_columns) do - print(row({ column, spec.title, spec.description })); - end - print [[]] - print [[Most fields on the internal session structures can also be used as columns]] - -- Also, you can pass a table column specification directly, with mapper callback and all + + local topic = data:match("^help (%w+)"); + if topic then + return def_env.help[topic]({ session = session }); + end + + print [[Commands are divided into multiple sections. For help on a particular section, ]] + print [[type: help SECTION (for example, 'help c2s'). Sections are: ]] + print [[]] + local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width) + print(row()) + for section_name, section in it.sorted_pairs(def_env) do + local section_mt = getmetatable(section); + local section_help = section_mt and section_mt.help; + print(row { section_name; section_help and section_help.desc or "" }); + end + + print(""); + + print [[In addition to info about commands, the following general topics are available:]] + + print(""); + for topic_name, topic_info in it.sorted_pairs(help_topics) do + print(topic_name .. " - "..topic_info.desc); end end @@ -350,10 +346,13 @@ end -- Anything in def_env will be accessible within the session as a global variable --luacheck: ignore 212/self -local serialize_defaults = module:get_option("console_prettyprint_settings", - { fatal = false; unquoted = true; maxdepth = 2; table_iterator = "pairs" }) +local serialize_defaults = module:get_option("console_prettyprint_settings", { + preset = "pretty"; + maxdepth = 2; + table_iterator = "pairs"; +}) -def_env.output = {}; +def_env.output = new_section("Configure admin console output"); function def_env.output:configure(opts) if type(opts) ~= "table" then opts = { preset = opts }; @@ -375,7 +374,57 @@ function def_env.output:configure(opts) self.session.serialize = serialization.new(opts); end -def_env.server = {}; +def_env.help = setmetatable({}, { + help = { + desc = "Show this help about available commands"; + commands = {}; + }; + __index = function (_, section_name) + return function (self) + local print = self.session.print; + local section_mt = getmetatable(def_env[section_name]); + local section_help = section_mt and section_mt.help; + + local c = 0; + + if section_help then + print("Help: "..section_name); + if section_help.desc then + print(section_help.desc); + end + print(("-"):rep(#(section_help.desc or section_name))); + print(""); + + if section_help.content then + print(section_help.content); + print(""); + end + + for command, command_help in it.sorted_pairs(section_help.commands or {}) do + c = c + 1; + local args = command_help.args:pluck("name"):concat(", "); + local desc = command_help.desc or command_help.module and ("Provided by mod_"..command_help.module) or ""; + print(("%s:%s(%s) - %s"):format(section_name, command, args, desc)); + end + elseif help_topics[section_name] then + local topic = help_topics[section_name]; + if type(topic.content) == "function" then + topic.content(self, print); + else + print(topic.content); + end + print(""); + return true, "Showing help topic '"..section_name.."'"; + else + print("Unknown topic: "..section_name); + end + print(""); + return true, ("%d command(s) listed"):format(c); + end; + end; +}); + +def_env.server = new_section("Uptime, version, shutting down, etc."); function def_env.server:insane_reload() prosody.unlock_globals(); @@ -384,10 +433,12 @@ function def_env.server:insane_reload() return true, "Server reloaded"; end +describe_command [[server:version() - Show the server's version number]] function def_env.server:version() return true, tostring(prosody.version or "unknown"); end +describe_command [[server:uptime() - Show how long the server has been running]] function def_env.server:uptime() local t = os.time()-prosody.start_time; local seconds = t%60; @@ -402,6 +453,7 @@ function def_env.server:uptime() minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time)); end +describe_command [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]] function def_env.server:shutdown(reason, code) prosody.shutdown(reason, code); return true, "Shutdown initiated"; @@ -411,6 +463,7 @@ local function human(kb) return format_number(kb*1024, "B", "b"); end +describe_command [[server:memory() - Show details about the server's memory usage]] function def_env.server:memory() if not has_pposix or not pposix.meminfo then return true, "Lua is using "..human(collectgarbage("count")); @@ -423,7 +476,7 @@ function def_env.server:memory() return true, "OK"; end -def_env.module = {}; +def_env.module = new_section("Commands to load/reload/unload modules/plugins"); local function get_hosts_set(hosts) if type(hosts) == "table" then @@ -469,6 +522,7 @@ local function get_hosts_with_module(hosts, module) return hosts_set; end +describe_command [[module:info(module, host) - Show information about a loaded module]] function def_env.module:info(name, hosts) if not name then return nil, "module name expected"; @@ -481,6 +535,16 @@ function def_env.module:info(name, hosts) local function item_name(item) return item.name; end + local function task_timefmt(t) + if not t then + return "no last run time" + elseif os.difftime(os.time(), t) < 86400 then + return os.date("last run today at %H:%M", t); + else + return os.date("last run %A at %H:%M", t); + end + end + local friendly_descriptions = { ["adhoc-provider"] = "Ad-hoc commands", ["auth-provider"] = "Authentication provider", @@ -498,12 +562,22 @@ function def_env.module:info(name, hosts) ["auth-provider"] = item_name, ["storage-provider"] = item_name, ["http-provider"] = function(item, mod) return mod:http_url(item.name, item.default_path); end, - ["net-provider"] = item_name, + ["net-provider"] = function(item) + local service_name = item.name; + local ports_list = {}; + for _, interface, port in portmanager.get_active_services():iter(service_name, nil, nil) do + table.insert(ports_list, "["..interface.."]:"..port); + end + if not ports_list[1] then + return service_name..": not listening on any ports"; + end + return service_name..": "..table.concat(ports_list, ", "); + end, ["measure"] = function(item) return item.name .. " (" .. suf(item.conf and item.conf.unit, " ") .. item.type .. ")"; end, ["metric"] = function(item) return ("%s (%s%s)%s"):format(item.name, suf(item.mf.unit, " "), item.mf.type_, pre(": ", item.mf.description)); end, - ["task"] = function (item) return string.format("%s (%s)", item.name or item.id, item.when); end + ["task"] = function (item) return string.format("%s (%s, %s)", item.name or item.id, item.when, task_timefmt(item.last)); end }; for host in hosts do @@ -533,21 +607,37 @@ function def_env.module:info(name, hosts) if mod.module.dependencies and next(mod.module.dependencies) ~= nil then print(" dependencies:"); for dep in pairs(mod.module.dependencies) do - print(" - mod_" .. dep); + -- Dependencies are per module instance, not per host, so dependencies + -- of/on global modules may list modules not actually loaded on the + -- current host. + if modulemanager.is_loaded(host, dep) then + print(" - mod_" .. dep); + end + end + end + if mod.module.reverse_dependencies and next(mod.module.reverse_dependencies) ~= nil then + print(" reverse dependencies:"); + for dep in pairs(mod.module.reverse_dependencies) do + if modulemanager.is_loaded(host, dep) then + print(" - mod_" .. dep); + end end end end return true; end -function def_env.module:load(name, hosts, config) +describe_command [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]] +function def_env.module:load(name, hosts) hosts = get_hosts_with_module(hosts); -- Load the module for each host local ok, err, count, mod = true, nil, 0; for host in hosts do + local configured_modules, component = modulemanager.get_modules_for_host(host); + if (not modulemanager.is_loaded(host, name)) then - mod, err = modulemanager.load(host, name, config); + mod, err = modulemanager.load(host, name); if not mod then ok = false; if err == "global-module-already-loaded" then @@ -560,6 +650,10 @@ function def_env.module:load(name, hosts, config) else count = count + 1; self.session.print("Loaded for "..mod.module.host); + + if not (configured_modules:contains(name) or name == component) then + self.session.print("Note: Module will not be loaded after restart unless enabled in configuration"); + end end end end @@ -567,12 +661,15 @@ function def_env.module:load(name, hosts, config) return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); end +describe_command [[module:unload(module, host) - The same, but just unloads the module from memory]] function def_env.module:unload(name, hosts) hosts = get_hosts_with_module(hosts, name); -- Unload the module for each host local ok, err, count = true, nil, 0; for host in hosts do + local configured_modules, component = modulemanager.get_modules_for_host(host); + if modulemanager.is_loaded(host, name) then ok, err = modulemanager.unload(host, name); if not ok then @@ -581,6 +678,10 @@ function def_env.module:unload(name, hosts) else count = count + 1; self.session.print("Unloaded from "..host); + + if configured_modules:contains(name) or name == component then + self.session.print("Note: Module will be loaded after restart unless disabled in configuration"); + end end end end @@ -593,6 +694,7 @@ local function _sort_hosts(a, b) else return a:gsub("[^.]+", string.reverse):reverse() < b:gsub("[^.]+", string.reverse):reverse(); end end +describe_command [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]] function def_env.module:reload(name, hosts) hosts = array.collect(get_hosts_with_module(hosts, name)):sort(_sort_hosts) @@ -616,6 +718,7 @@ function def_env.module:reload(name, hosts) return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); end +describe_command [[module:list(host) - List the modules loaded on the specified host]] function def_env.module:list(hosts) hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts); @@ -642,9 +745,10 @@ function def_env.module:list(hosts) end end -def_env.config = {}; +def_env.config = new_section("Reloading the configuration, etc."); + function def_env.config:load(filename, format) - local config_load = require "core.configmanager".load; + local config_load = require "prosody.core.configmanager".load; local ok, err = config_load(filename, format); if not ok then return false, err or "Unknown error loading config"; @@ -652,20 +756,30 @@ function def_env.config:load(filename, format) return true, "Config loaded"; end +describe_command [[config:get([host,] option) - Show the value of a config option.]] function def_env.config:get(host, key) if key == nil then host, key = "*", host; end - local config_get = require "core.configmanager".get + local config_get = require "prosody.core.configmanager".get return true, serialize_config(config_get(host, key)); end +describe_command [[config:set([host,] option, value) - Update the value of a config option without writing to the config file.]] +function def_env.config:set(host, key, value) + if host ~= "*" and not prosody.hosts[host] then + host, key, value = "*", host, key; + end + return require "prosody.core.configmanager".set(host, key, value); +end + +describe_command [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]] function def_env.config:reload() local ok, err = prosody.reload_config(); return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err); end -def_env.c2s = {}; +def_env.c2s = new_section("Commands to manage local client-to-server sessions"); local function get_jid(session) if session.username then @@ -702,6 +816,7 @@ local function show_c2s(callback) end); end +describe_command [[c2s:count() - Count sessions without listing them]] function def_env.c2s:count() local c2s = get_c2s(); return true, "Total: ".. #c2s .." clients"; @@ -719,7 +834,7 @@ available_columns = { jid = { title = "JID"; description = "Full JID of user session"; - width = 32; + width = "3p"; key = "full_jid"; mapper = function(full_jid, session) return full_jid or get_jid(session) end; }; @@ -727,7 +842,7 @@ available_columns = { title = "Host"; description = "Local hostname"; key = "host"; - width = 22; + width = "1p"; mapper = function(host, session) return host or get_s2s_hosts(session) or "?"; end; @@ -735,7 +850,7 @@ available_columns = { remote = { title = "Remote"; description = "Remote hostname"; - width = 22; + width = "1p"; mapper = function(_, session) return select(2, get_s2s_hosts(session)); end; @@ -743,7 +858,7 @@ available_columns = { port = { title = "Port"; description = "Server port used"; - width = 5; + width = #string.format("%d", 0xffff); -- max 16 bit unsigned integer align = "right"; key = "conn"; mapper = function(conn) @@ -752,10 +867,22 @@ available_columns = { end end; }; + created = { + title = "Connection Created"; + description = "Time when connection was created"; + width = #"YYYY MM DD HH:MM:SS"; + align = "right"; + key = "conn"; + mapper = function(conn) + if conn then + return os.date("%F %T", math.floor(conn.created)); + end + end; + }; dir = { title = "Dir"; description = "Direction of server-to-server connection"; - width = 3; + width = #"<->"; key = "direction"; mapper = function(dir, session) if session.incoming and session.outgoing then return "<->"; end @@ -763,12 +890,23 @@ available_columns = { if dir == "incoming" then return "<--"; end end; }; - id = { title = "Session ID"; description = "Internal session ID used in logging"; width = 20; key = "id" }; - type = { title = "Type"; description = "Session type"; width = #"c2s_unauthed"; key = "type" }; + id = { + title = "Session ID"; + description = "Internal session ID used in logging"; + -- Depends on log16(?) of pointers which may vary over runtime, so + some margin + width = math.max(#"c2s", #"s2sin", #"s2sout") + #(tostring({}):match("%x+$")) + 2; + key = "id"; + }; + type = { + title = "Type"; + description = "Session type"; + width = math.max(#"c2s_unauthed", #"s2sout_unauthed"); + key = "type"; + }; method = { title = "Method"; description = "Connection method"; - width = 10; + width = math.max(#"BOSH", #"WebSocket", #"TCP"); mapper = function(_, session) if session.bosh_version then return "BOSH"; @@ -782,15 +920,20 @@ available_columns = { ipv = { title = "IPv"; description = "Internet Protocol version (4 or 6)"; - width = 4; + width = #"IPvX"; key = "ip"; mapper = function(ip) if ip then return ip:find(":") and "IPv6" or "IPv4"; end end; }; - ip = { title = "IP address"; description = "IP address the session connected from"; width = 40; key = "ip" }; + ip = { + title = "IP address"; + description = "IP address the session connected from"; + width = module:get_option_boolean("use_ipv6", true) and #"ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" or #"198.051.100.255"; + key = "ip"; + }; status = { title = "Status"; description = "Presence status"; - width = 6; + width = math.max(#"online", #"chat"); key = "presence"; mapper = function(p) if not p then return ""; end @@ -801,24 +944,22 @@ available_columns = { title = "Security"; description = "TLS version or security status"; key = "conn"; - width = 8; + width = math.max(#"secure", #"TLSvX.Y"); mapper = function(conn, session) if not session.secure then return "insecure"; end if not conn or not conn:ssl() then return "secure" end - local sock = conn and conn:socket(); - if not sock then return "secure"; end - local tls_info = sock.info and sock:info(); + local tls_info = conn.ssl_info and conn:ssl_info(); return tls_info and tls_info.protocol or "secure"; end; }; encryption = { title = "Encryption"; description = "Encryption algorithm used (TLS cipher suite)"; - width = 30; + -- openssl ciphers 'ALL:COMPLEMENTOFALL' | tr : \\n | awk 'BEGIN {n=1} length() > n {n=length()} END {print(n)}' + width = #"ECDHE-ECDSA-CHACHA20-POLY1305"; key = "conn"; mapper = function(conn) - local sock = conn and conn:socket(); - local info = sock and sock.info and sock:info(); + local info = conn and conn.ssl_info and conn:ssl_info(); if info then return info.cipher end end; }; @@ -826,27 +967,36 @@ available_columns = { title = "Certificate"; description = "Validation status of certificate"; key = "cert_identity_status"; - width = 11; + width = math.max(#"Expired", #"Self-signed", #"Untrusted", #"Mismatched", #"Unknown"); mapper = function(cert_status, session) - if cert_status then return capitalize(cert_status); end - if session.cert_chain_status == "invalid" then + if cert_status == "invalid" then + -- non-nil cert_identity_status implies valid chain, which covers just + -- about every error condition except mismatched certificate names + return "Mismatched"; + elseif cert_status then + -- basically only "valid" + return capitalize(cert_status); + end + -- no certificate status, + if type(session.cert_chain_errors) == "table" then local cert_errors = set.new(session.cert_chain_errors[1]); if cert_errors:contains("certificate has expired") then return "Expired"; elseif cert_errors:contains("self signed certificate") then return "Self-signed"; end + -- Some other cert issue, or something up the chain + -- TODO borrow more logic from mod_s2s/friendly_cert_error() return "Untrusted"; - elseif session.cert_identity_status == "invalid" then - return "Mismatched"; end + -- TODO cert_chain_errors can be a string, handle that return "Unknown"; end; }; sni = { title = "SNI"; description = "Hostname requested in TLS"; - width = 22; + width = "1p"; -- same as host, remote etc mapper = function(_, session) if not session.conn then return end local sock = session.conn:socket(); @@ -856,7 +1006,7 @@ available_columns = { alpn = { title = "ALPN"; description = "Protocol requested in TLS"; - width = 11; + width = math.max(#"http/1.1", #"xmpp-client", #"xmpp-server"); mapper = function(_, session) if not session.conn then return end local sock = session.conn:socket(); @@ -867,7 +1017,8 @@ available_columns = { title = "SM"; description = "Stream Management (XEP-0198) status"; key = "smacks"; - width = 11; + -- FIXME shorter synonym for hibernating + width = math.max(#"yes", #"no", #"hibernating"); mapper = function(smacks_xmlns, session) if not smacks_xmlns then return "no"; end if session.hibernating then return "hibernating"; end @@ -901,7 +1052,7 @@ available_columns = { title = "Dialback"; description = "Legacy server verification"; key = "dialback_key"; - width = 13; + width = math.max(#"Not used", #"Not initiated", #"Initiated", #"Completed"); mapper = function (dialback_key, session) if not dialback_key then if session.type == "s2sin" or session.type == "s2sout" then @@ -915,6 +1066,16 @@ available_columns = { end end }; + role = { + title = "Role"; + description = "Session role with 'prosody:' prefix removed"; + width = "1p"; + key = "role"; + mapper = function(role) + local name = role and role.name; + return name and name:match"^prosody:(%w+)" or name; + end; + } }; local function get_colspec(colspec, default) @@ -922,7 +1083,7 @@ local function get_colspec(colspec, default) local columns = {}; for i, col in pairs(colspec or default) do if type(col) == "string" then - columns[i] = available_columns[col] or { title = capitalize(col); width = 20; key = col }; + columns[i] = available_columns[col] or { title = capitalize(col); width = "1p"; key = col }; elseif type(col) ~= "table" then return false, ("argument %d: expected string|table but got %s"):format(i, type(col)); else @@ -933,14 +1094,15 @@ local function get_colspec(colspec, default) return columns; end +describe_command [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]] function def_env.c2s:show(match_jid, colspec) local print = self.session.print; - local columns = get_colspec(colspec, { "id"; "jid"; "ipv"; "status"; "secure"; "smacks"; "csi" }); - local row = format_table(columns, 120); + local columns = get_colspec(colspec, { "id"; "jid"; "role"; "ipv"; "status"; "secure"; "smacks"; "csi" }); + local row = format_table(columns, self.session.width); local function match(session) local jid = get_jid(session) - return (not match_jid) or jid_compare(jid, match_jid); + return (not match_jid) or match_jid == "*" or jid_compare(jid, match_jid); end local group_by_host = true; @@ -973,6 +1135,7 @@ function def_env.c2s:show(match_jid, colspec) return true, ("%d c2s sessions shown"):format(total_count); end +describe_command [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]] function def_env.c2s:show_tls(match_jid) return self:show(match_jid, { "jid"; "id"; "secure"; "encryption" }); end @@ -986,6 +1149,7 @@ local function build_reason(text, condition) end end +describe_command [[c2s:close(jid) - Close all sessions for the specified JID]] function def_env.c2s:close(match_jid, text, condition) local count = 0; show_c2s(function (jid, session) @@ -997,6 +1161,7 @@ function def_env.c2s:close(match_jid, text, condition) return true, "Total: "..count.." sessions closed"; end +describe_command [[c2s:closeall() - Close all active c2s connections ]] function def_env.c2s:closeall(text, condition) local count = 0; --luacheck: ignore 212/jid @@ -1008,7 +1173,8 @@ function def_env.c2s:closeall(text, condition) end -def_env.s2s = {}; +def_env.s2s = new_section("Commands to manage sessions between this server and others"); + local function _sort_s2s(a, b) local a_local, a_remote = get_s2s_hosts(a); local b_local, b_remote = get_s2s_hosts(b); @@ -1016,14 +1182,31 @@ local function _sort_s2s(a, b) return _sort_hosts(a_local or "", b_local or ""); end +local function match_wildcard(match_jid, jid) + -- host == host or (host) == *.(host) or sub(.host) == *(.host) + return jid == match_jid or jid == match_jid:sub(3) or jid:sub(-#match_jid + 1) == match_jid:sub(2); +end + +local function match_s2s_jid(session, match_jid) + local host, remote = get_s2s_hosts(session); + if not match_jid or match_jid == "*" then + return true; + elseif host == match_jid or remote == match_jid then + return true; + elseif match_jid:sub(1, 2) == "*." then + return match_wildcard(match_jid, host) or match_wildcard(match_jid, remote); + end + return false; +end + +describe_command [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]] function def_env.s2s:show(match_jid, colspec) local print = self.session.print; local columns = get_colspec(colspec, { "id"; "host"; "dir"; "remote"; "ipv"; "secure"; "s2s_sasl"; "dialback" }); - local row = format_table(columns, 132); + local row = format_table(columns, self.session.width); local function match(session) - local host, remote = get_s2s_hosts(session); - return not match_jid or host == match_jid or remote == match_jid; + return match_s2s_jid(session, match_jid); end local group_by_host = true; @@ -1057,6 +1240,7 @@ function def_env.s2s:show(match_jid, colspec) return true, ("%d s2s connections shown"):format(total_count); end +describe_command [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]] function def_env.s2s:show_tls(match_jid) return self:show(match_jid, { "id"; "host"; "dir"; "remote"; "secure"; "encryption"; "cert" }); end @@ -1090,7 +1274,7 @@ function def_env.s2s:showcert(domain) local print = self.session.print; local s2s_sessions = module:shared"/*/s2s/sessions"; local domain_sessions = set.new(array.collect(values(s2s_sessions))) - /function(session) return (session.to_host == domain or session.from_host == domain) and session or nil; end; + /function(session) return match_s2s_jid(session, domain) and session or nil; end; local cert_set = {}; for session in domain_sessions do local conn = session.conn; @@ -1179,6 +1363,7 @@ function def_env.s2s:showcert(domain) .." presented by "..domain.."."); end +describe_command [[s2s:close(from, to) - Close a connection from one domain to another]] function def_env.s2s:close(from, to, text, condition) local print, count = self.session.print, 0; local s2s_sessions = module:shared"/*/s2s/sessions"; @@ -1193,22 +1378,22 @@ function def_env.s2s:close(from, to, text, condition) end for _, session in pairs(s2s_sessions) do - local id = session.id or (session.type..tostring(session):match("[a-f0-9]+$")); - if (match_id and match_id == id) - or (session.from_host == from and session.to_host == to) then + local id = session.id or (session.type .. tostring(session):match("[a-f0-9]+$")); + if (match_id and match_id == id) or ((from and match_wildcard(from, session.to_host)) or (to and match_wildcard(to, session.to_host))) then print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id)); (session.close or s2smanager.destroy_session)(session, build_reason(text, condition)); - count = count + 1 ; + count = count + 1; end end return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end +describe_command [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]] function def_env.s2s:closeall(host, text, condition) local count = 0; local s2s_sessions = module:shared"/*/s2s/sessions"; for _,session in pairs(s2s_sessions) do - if not host or session.from_host == host or session.to_host == host then + if not host or host == "*" or match_s2s_jid(session, host) then session:close(build_reason(text, condition)); count = count + 1; end @@ -1217,37 +1402,42 @@ function def_env.s2s:closeall(host, text, condition) else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end end -def_env.host = {}; def_env.hosts = def_env.host; +def_env.host = new_section("Commands to activate, deactivate and list virtual hosts"); +describe_command [[host:activate(hostname) - Activates the specified host]] function def_env.host:activate(hostname, config) return hostmanager.activate(hostname, config); end + +describe_command [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]] function def_env.host:deactivate(hostname, reason) return hostmanager.deactivate(hostname, reason); end +describe_command [[host:list() - List the currently-activated hosts]] function def_env.host:list() local print = self.session.print; local i = 0; - local type; + local host_type; for host, host_session in iterators.sorted_pairs(prosody.hosts, _sort_hosts) do i = i + 1; - type = host_session.type; - if type == "local" then + host_type = host_session.type; + if host_type == "local" then print(host); else - type = module:context(host):get_option_string("component_module", type); - if type ~= "component" then - type = type .. " component"; + host_type = module:context(host):get_option_string("component_module", host_type); + if host_type ~= "component" then + host_type = host_type .. " component"; end - print(("%s (%s)"):format(host, type)); + print(("%s (%s)"):format(host, host_type)); end end return true, i.." hosts"; end -def_env.port = {}; +def_env.port = new_section("Commands to manage ports the server is listening on"); +describe_command [[port:list() - Lists all network ports prosody currently listens on]] function def_env.port:list() local print = self.session.print; local services = portmanager.get_active_services().data; @@ -1266,6 +1456,7 @@ function def_env.port:list() return true, n_services.." services listening on "..n_ports.." ports"; end +describe_command [[port:close(port, interface) - Close a port]] function def_env.port:close(close_port, close_interface) close_port = assert(tonumber(close_port), "Invalid port number"); local n_closed = 0; @@ -1288,7 +1479,7 @@ function def_env.port:close(close_port, close_interface) return true, "Closed "..n_closed.." ports"; end -def_env.muc = {}; +def_env.muc = new_section("Commands to create, list and manage chat rooms"); local console_room_mt = { __index = function (self, k) return self.room[k]; end; @@ -1307,6 +1498,21 @@ local function check_muc(jid) return room_name, host; end +local function get_muc(room_jid) + local room_name, host = check_muc(room_jid); + if not room_name then + return room_name, host; + end + local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid); + if not room_obj then + return nil, "No such room: "..room_jid; + end + return room_obj; +end + +local muc_util = module:require"muc/util"; + +describe_command [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]] function def_env.muc:create(room_jid, config) local room_name, host = check_muc(room_jid); if not room_name then @@ -1318,18 +1524,16 @@ function def_env.muc:create(room_jid, config) return prosody.hosts[host].modules.muc.create_room(room_jid, config); end +describe_command [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]] function def_env.muc:room(room_jid) - local room_name, host = check_muc(room_jid); - if not room_name then - return room_name, host; - end - local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid); + local room_obj, err = get_muc(room_jid); if not room_obj then - return nil, "No such room: "..room_jid; + return room_obj, err; end return setmetatable({ room = room_obj }, console_room_mt); end +describe_command [[muc:list(host) - List rooms on the specified MUC component]] function def_env.muc:list(host) local host_session = prosody.hosts[host]; if not host_session or not host_session.modules.muc then @@ -1344,36 +1548,160 @@ function def_env.muc:list(host) return true, c.." rooms"; end -local um = require"core.usermanager"; +describe_command [[muc:occupants(roomjid, filter) - List room occupants, optionally filtered on substring or role]] +function def_env.muc:occupants(room_jid, filter) + local room_obj, err = get_muc(room_jid); + if not room_obj then + return room_obj, err; + end + + local print = self.session.print; + local row = format_table({ + { title = "Role"; width = 12; key = "role" }; -- longest role name + { title = "JID"; width = "75%"; key = "bare_jid" }; + { title = "Nickname"; width = "25%"; key = "nick"; mapper = jid_resource }; + }, self.session.width); + local occupants = array.collect(iterators.select(2, room_obj:each_occupant())); + local total = #occupants; + if filter then + occupants:filter(function(occupant) + return occupant.role == filter or jid_resource(occupant.nick):find(filter, 1, true); + end); + end + local displayed = #occupants; + occupants:sort(function(a, b) + if a.role ~= b.role then + return muc_util.valid_roles[a.role] > muc_util.valid_roles[b.role]; + else + return a.bare_jid < b.bare_jid; + end + end); + + if displayed == 0 then + return true, ("%d out of %d occupant%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end + + print(row()); + for _, occupant in ipairs(occupants) do + print(row(occupant)); + end + + if total == displayed then + return true, ("%d occupant%s listed"):format(total, total ~= 1 and "s" or "") + else + return true, ("%d out of %d occupant%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end +end + +describe_command [[muc:affiliations(roomjid, filter) - List affiliated members of the room, optionally filtered on substring or affiliation]] +function def_env.muc:affiliations(room_jid, filter) + local room_obj, err = get_muc(room_jid); + if not room_obj then + return room_obj, err; + end + + local print = self.session.print; + local row = format_table({ + { title = "Affiliation"; width = 12 }; -- longest affiliation name + { title = "JID"; width = "75%" }; + { title = "Nickname"; width = "25%"; key = "reserved_nickname" }; + }, self.session.width); + local affiliated = array(); + for affiliated_jid, affiliation, affiliation_data in room_obj:each_affiliation() do + affiliated:push(setmetatable({ affiliation; affiliated_jid }, { __index = affiliation_data })); + end + + local total = #affiliated; + if filter then + affiliated:filter(function(affiliation) + return filter == affiliation[1] or affiliation[2]:find(filter, 1, true); + end); + end + local displayed = #affiliated; + local aff_ranking = muc_util.valid_affiliations; + affiliated:sort(function(a, b) + if a[1] ~= b[1] then + return aff_ranking[a[1]] > aff_ranking[b[1]]; + else + return a[2] < b[2]; + end + end); + + if displayed == 0 then + return true, ("%d out of %d affiliations%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end + + print(row()); + for _, affiliation in ipairs(affiliated) do + print(row(affiliation)); + end + -local function coerce_roles(roles) - if roles == "admin" then roles = "prosody:admin"; end - if type(roles) == "string" then roles = { [roles] = true }; end - if roles[1] then for i, role in ipairs(roles) do roles[role], roles[i] = true, nil; end end - return roles; + if total == displayed then + return true, ("%d affiliation%s listed"):format(total, total ~= 1 and "s" or "") + else + return true, ("%d out of %d affiliation%s listed"):format(displayed, total, total ~= 1 and "s" or "") + end end -def_env.user = {}; -function def_env.user:create(jid, password, roles) +local um = require"prosody.core.usermanager"; + +def_env.user = new_section("Commands to create and delete users, and change their passwords"); + +describe_command [[user:create(jid, password, role) - Create the specified user account]] +function def_env.user:create(jid, password, role) local username, host = jid_split(jid); if not prosody.hosts[host] then return nil, "No such host: "..host; elseif um.user_exists(username, host) then return nil, "User exists"; end - local ok, err = um.create_user(username, password, host); + + if not role then + role = module:get_option_string("default_provisioned_role", "prosody:member"); + end + + local ok, err = um.create_user_with_role(username, password, host, role); + if not ok then + return nil, "Could not create user: "..err; + end + + return true, ("Created %s with role '%s'"):format(jid, role); +end + +describe_command [[user:disable(jid) - Disable the specified user account, preventing login]] +function def_env.user:disable(jid) + local username, host = jid_split(jid); + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif not um.user_exists(username, host) then + return nil, "No such user"; + end + local ok, err = um.disable_user(username, host); if ok then - if ok and roles then - roles = coerce_roles(roles); - local roles_ok, rerr = um.set_roles(jid, host, roles); - if not roles_ok then return nil, "User created, but could not set roles: " .. tostring(rerr); end - end - return true, "User created"; + return true, "User disabled"; else - return nil, "Could not create user: "..err; + return nil, "Could not disable user: "..err; end end +describe_command [[user:enable(jid) - Enable the specified user account, restoring login access]] +function def_env.user:enable(jid) + local username, host = jid_split(jid); + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif not um.user_exists(username, host) then + return nil, "No such user"; + end + local ok, err = um.enable_user(username, host); + if ok then + return true, "User enabled"; + else + return nil, "Could not enable user: "..err; + end +end + +describe_command [[user:delete(jid) - Permanently remove the specified user account]] function def_env.user:delete(jid) local username, host = jid_split(jid); if not prosody.hosts[host] then @@ -1389,6 +1717,7 @@ function def_env.user:delete(jid) end end +describe_command [[user:password(jid, password) - Set the password for the specified user account]] function def_env.user:password(jid, password) local username, host = jid_split(jid); if not prosody.hosts[host] then @@ -1404,43 +1733,71 @@ function def_env.user:password(jid, password) end end -function def_env.user:roles(jid, host, new_roles) - if new_roles or type(host) == "table" then - return nil, "Use user:setroles(jid, host, roles) to change user roles"; - end +describe_command [[user:roles(jid, host) - Show current roles for an user]] +function def_env.user:role(jid, host) + local print = self.session.print; local username, userhost = jid_split(jid); if host == nil then host = userhost; end - if host ~= "*" and not prosody.hosts[host] then + if not prosody.hosts[host] then return nil, "No such host: "..host; elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then return nil, "No such user"; end - local roles = um.get_roles(jid, host); - if not roles then return true, "No roles"; end - local count = 0; - local print = self.session.print; - for role in pairs(roles) do + + local primary_role = um.get_user_role(username, host); + local secondary_roles = um.get_user_secondary_roles(username, host); + + print(primary_role and primary_role.name or "<none>"); + + local count = primary_role and 1 or 0; + for role_name in pairs(secondary_roles or {}) do count = count + 1; - print(role); + print(role_name.." (secondary)"); end + return true, count == 1 and "1 role" or count.." roles"; end -def_env.user.showroles = def_env.user.roles; -- COMPAT +def_env.user.roles = def_env.user.role; --- user:roles("someone@example.com", "example.com", {"prosody:admin"}) --- user:roles("someone@example.com", {"prosody:admin"}) -function def_env.user:setroles(jid, host, new_roles) +describe_command [[user:setrole(jid, host, role) - Set primary role of a user (see 'help roles')]] +-- user:setrole("someone@example.com", "example.com", "prosody:admin") +-- user:setrole("someone@example.com", "prosody:admin") +function def_env.user:setrole(jid, host, new_role) local username, userhost = jid_split(jid); - if new_roles == nil then host, new_roles = userhost, host; end - if host ~= "*" and not prosody.hosts[host] then + if new_role == nil then host, new_role = userhost, host; end + if not prosody.hosts[host] then return nil, "No such host: "..host; elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then return nil, "No such user"; end - if host == "*" then host = nil; end - return um.set_roles(jid, host, coerce_roles(new_roles)); + return um.set_user_role(username, host, new_role); end +describe_command [[user:addrole(jid, host, role) - Add a secondary role to a user]] +function def_env.user:addrole(jid, host, new_role) + local username, userhost = jid_split(jid); + if new_role == nil then host, new_role = userhost, host; end + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then + return nil, "No such user"; + end + return um.add_user_secondary_role(username, host, new_role); +end + +describe_command [[user:delrole(jid, host, role) - Remove a secondary role from a user]] +function def_env.user:delrole(jid, host, role_name) + local username, userhost = jid_split(jid); + if role_name == nil then host, role_name = userhost, host; end + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then + return nil, "No such user"; + end + return um.remove_user_secondary_role(username, host, role_name); +end + +describe_command [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]] -- TODO switch to table view, include roles function def_env.user:list(host, pat) if not host then @@ -1460,9 +1817,10 @@ function def_env.user:list(host, pat) return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users"; end -def_env.xmpp = {}; +def_env.xmpp = new_section("Commands for sending XMPP stanzas"); -local new_id = require "util.id".medium; +describe_command [[xmpp:ping(localhost, remotehost) - Sends a ping to a remote XMPP server and reports the response]] +local new_id = require "prosody.util.id".medium; function def_env.xmpp:ping(localhost, remotehost, timeout) localhost = select(2, jid_split(localhost)); remotehost = select(2, jid_split(remotehost)); @@ -1509,12 +1867,12 @@ function def_env.xmpp:ping(localhost, remotehost, timeout) module:unhook("s2sin-established", onestablished); module:unhook("s2s-destroyed", ondestroyed); end):next(function(pong) - return ("pong from %s in %gs"):format(pong.stanza.attr.from, time.now() - time_start); + return ("pong from %s on %s in %gs"):format(pong.stanza.attr.from, pong.origin.id, time.now() - time_start); end); end -def_env.dns = {}; -local adns = require"net.adns"; +def_env.dns = new_section("Commands to manage and inspect the internal DNS resolver"); +local adns = require"prosody.net.adns"; local function get_resolver(session) local resolver = session.dns_resolver; @@ -1525,43 +1883,54 @@ local function get_resolver(session) return resolver; end +describe_command [[dns:lookup(name, type, class) - Do a DNS lookup]] function def_env.dns:lookup(name, typ, class) local resolver = get_resolver(self.session); return resolver:lookup_promise(name, typ, class) end +describe_command [[dns:addnameserver(nameserver) - Add a nameserver to the list]] function def_env.dns:addnameserver(...) local resolver = get_resolver(self.session); resolver._resolver:addnameserver(...) return true end +describe_command [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]] function def_env.dns:setnameserver(...) local resolver = get_resolver(self.session); resolver._resolver:setnameserver(...) return true end +describe_command [[dns:purge() - Clear the DNS cache]] function def_env.dns:purge() local resolver = get_resolver(self.session); resolver._resolver:purge() return true end +describe_command [[dns:cache() - Show cached records]] function def_env.dns:cache() local resolver = get_resolver(self.session); return true, "Cache:\n"..tostring(resolver._resolver.cache) end -def_env.http = {}; +def_env.http = new_section("Commands to inspect HTTP services"); +describe_command [[http:list(hosts) - Show HTTP endpoints]] function def_env.http:list(hosts) local print = self.session.print; hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts); - local output = format_table({ - { title = "Module", width = "20%" }, - { title = "URL", width = "80%" }, - }, 132); + local output_simple = format_table({ + { title = "Module"; width = "1p" }; + { title = "External URL"; width = "6p" }; + }, self.session.width); + local output_split = format_table({ + { title = "Module"; width = "1p" }; + { title = "External URL"; width = "3p" }; + { title = "Internal URL"; width = "3p" }; + }, self.session.width); for _, host in ipairs(hosts) do local http_apps = modulemanager.get_items("http-provider", host); @@ -1572,12 +1941,14 @@ function def_env.http:list(hosts) else print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":")); end - print(output()); + print(output_split()); for _, provider in ipairs(http_apps) do local mod = provider._provided_by; - local url = module:context(host):http_url(provider.name, provider.default_path); + local external = module:context(host):http_url(provider.name, provider.default_path); + local internal = module:context(host):http_url(provider.name, provider.default_path, "internal"); + if external==internal then internal="" end mod = mod and "mod_"..mod or "" - print(output{mod, url}); + print((internal=="" and output_simple or output_split){mod, external, internal}); end print(""); end @@ -1592,18 +1963,83 @@ function def_env.http:list(hosts) return true; end -def_env.debug = {}; +def_env.watch = new_section("Commands for watching live logs from the server"); + +describe_command [[watch:log() - Follow debug logs]] +function def_env.watch:log() + local writing = false; + local sink = logger.add_simple_sink(function (source, level, message) + if writing then return; end + writing = true; + self.session.print(source, level, message); + writing = false; + end); + + while self.session.is_connected() do + async.sleep(3); + end + if not logger.remove_sink(sink) then + module:log("warn", "Unable to remove watch:log() sink"); + end +end + +describe_command [[watch:stanzas(target, filter) - Watch live stanzas matching the specified target and filter]] +local stanza_watchers = module:require("mod_debug_stanzas/watcher"); +function def_env.watch:stanzas(target_spec, filter_spec) + local function handler(event_type, stanza, session) + if stanza then + if event_type == "sent" then + self.session.print(("\n<!-- sent to %s -->"):format(session.id)); + elseif event_type == "received" then + self.session.print(("\n<!-- received from %s -->"):format(session.id)); + else + self.session.print(("\n<!-- %s (%s) -->"):format(event_type, session.id)); + end + self.session.print(stanza); + elseif session then + self.session.print("\n<!-- session "..session.id.." "..event_type.." -->"); + elseif event_type then + self.session.print("\n<!-- "..event_type.." -->"); + end + end + stanza_watchers.add({ + target_spec = { + jid = target_spec; + }; + filter_spec = filter_spec and { + with_jid = filter_spec; + }; + }, handler); + + while self.session.is_connected() do + async.sleep(3); + end + + stanza_watchers.remove(handler); +end + +def_env.debug = new_section("Commands for debugging the server"); + +describe_command [[debug:logevents(host) - Enable logging of fired events on host]] function def_env.debug:logevents(host) - helpers.log_host_events(host); + if host == "*" then + helpers.log_events(prosody.events); + elseif host == "http" then + helpers.log_events(require "prosody.net.http.server"._events); + return true + else + helpers.log_host_events(host); + end return true; end +describe_command [[debug:events(host, event) - Show registered event handlers]] function def_env.debug:events(host, event) local events_obj; if host and host ~= "*" then if host == "http" then - events_obj = require "net.http.server"._events; + events_obj = require "prosody.net.http.server"._events; elseif not prosody.hosts[host] then return false, "Unknown host: "..host; else @@ -1615,9 +2051,10 @@ function def_env.debug:events(host, event) return true, helpers.show_events(events_obj, event); end +describe_command [[debug:timers() - Show information about scheduled timers]] function def_env.debug:timers() local print = self.session.print; - local add_task = require"util.timer".add_task; + local add_task = require"prosody.util.timer".add_task; local h, params = add_task.h, add_task.params; local function normalize_time(t) return t; @@ -1671,10 +2108,70 @@ function def_env.debug:timers() return true; end --- COMPAT: debug:timers() was timer:info() for some time in trunk -def_env.timer = { info = def_env.debug.timers }; +describe_command [[debug:async() - Show information about pending asynchronous tasks]] +function def_env.debug:async(runner_id) + local print = self.session.print; + local time_now = time.now(); + + if runner_id then + for runner, since in pairs(async.waiting_runners) do + if runner.id == runner_id then + print("ID ", runner.id); + local f = runner.func; + if f == async.default_runner_func then + print("Function ", tostring(runner.current_item).." (from work queue)"); + else + print("Function ", tostring(f)); + if st.is_stanza(runner.current_item) then + print("Stanza:") + print("\t"..runner.current_item:indent(2):pretty_print()); + else + print("Work item", self.session.serialize(runner.current_item, "debug")); + end + end + + print("Coroutine ", tostring(runner.thread).." ("..coroutine.status(runner.thread)..")"); + print("Since ", since); + print("Status ", ("%s since %s (%0.2f seconds ago)"):format(runner.state, os.date("%Y-%m-%d %R:%S", math.floor(since)), time_now-since)); + print(""); + print(debug.traceback(runner.thread)); + return true, "Runner is "..runner.state; + end + end + return nil, "Runner not found or is currently idle"; + end -def_env.stats = {}; + local row = format_table({ + { title = "ID"; width = 12 }; + { title = "Function"; width = "10p" }; + { title = "Status"; width = "16" }; + { title = "Location"; width = "10p" }; + }, self.session.width); + print(row()) + + local c = 0; + for runner, since in pairs(async.waiting_runners) do + c = c + 1; + local f = runner.func; + if f == async.default_runner_func then + f = runner.current_item; + end + -- We want to fetch the location in the code that the runner yielded from, + -- excluding util.async's wrapper code. A level of `2` assumes that we + -- yielded directly from a function in util.async. This is *currently* true + -- of all util.async yields, but it's fragile. + local location = debug.getinfo(runner.thread, 2); + print(row { + runner.id; + tostring(f); + ("%s (%0.2fs)"):format(runner.state, time_now - since); + location.short_src..(location.currentline and ":"..location.currentline or ""); + }); + end + return true, ("%d runners pending"):format(c); +end + +def_env.stats = new_section("Commands to show internal statistics"); local short_units = { seconds = "s", @@ -1913,8 +2410,10 @@ local function new_stats_context(self) return setmetatable({ session = self.session, stats = true, now = time.now() }, stats_mt); end +describe_command [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern.]] +-- Undocumented currently, you can append :histogram() or :cfgraph() to stats:show() for rendered graphs. function def_env.stats:show(name_filter) - local statsman = require "core.statsmanager" + local statsman = require "prosody.core.statsmanager" local collect = statsman.collect if collect then -- force collection if in manual mode @@ -1934,6 +2433,176 @@ function def_env.stats:show(name_filter) return displayed_stats; end +local command_metadata_schema = { + type = "object"; + properties = { + section = { type = "string" }; + section_desc = { type = "string" }; + + name = { type = "string" }; + desc = { type = "string" }; + help = { type = "string" }; + args = { + type = "array"; + items = { + type = "object"; + properties = { + name = { type = "string", required = true }; + type = { type = "string", required = false }; + }; + }; + }; + }; + + required = { "name", "section", "desc", "args" }; +}; + +-- host_commands[section..":"..name][host] = handler +-- host_commands[section..":"..name][false] = metadata +local host_commands = {}; + +local function new_item_handlers(command_host) + local function on_command_added(event) + local command = event.item; + local mod_name = command._provided_by and ("mod_"..command._provided_by) or "<unknown module>"; + if not schema.validate(command_metadata_schema, command) or type(command.handler) ~= "function" then + module:log("warn", "Ignoring command added by %s: missing or invalid data", mod_name); + return; + end + + local handler = command.handler; + + if command_host then + if type(command.host_selector) ~= "string" then + module:log("warn", "Ignoring command %s:%s() added by %s - missing/invalid host_selector", command.section, command.name, mod_name); + return; + end + local qualified_name = command.section..":"..command.name; + local host_command_info = host_commands[qualified_name]; + if not host_command_info then + local selector_index; + for i, arg in ipairs(command.args) do + if arg.name == command.host_selector then + selector_index = i + 1; -- +1 to account for 'self' + break; + end + end + if not selector_index then + module:log("warn", "Command %s() host selector argument '%s' not found - not registering", qualified_name, command.host_selector); + return; + end + host_command_info = { + [false] = { + host_selector = command.host_selector; + handler = function (...) + local selected_host = select(2, jid_split((select(selector_index, ...)))); + if type(selected_host) ~= "string" then + return nil, "Invalid or missing argument '"..command.host_selector.."'"; + end + if not prosody.hosts[selected_host] then + return nil, "Unknown host: "..selected_host; + end + local host_handler = host_commands[qualified_name][selected_host]; + if not host_handler then + return nil, "This command is not available on "..selected_host; + end + return host_handler(...); + end; + }; + }; + host_commands[qualified_name] = host_command_info; + end + if host_command_info[command_host] then + module:log("warn", "Command %s() is already registered - overwriting with %s", qualified_name, mod_name); + end + host_command_info[command_host] = handler; + end + + local section_t = def_env[command.section]; + if not section_t then + section_t = {}; + def_env[command.section] = section_t; + end + + if command_host then + section_t[command.name] = host_commands[command.section..":"..command.name][false].handler; + else + section_t[command.name] = command.handler; + end + + local section_mt = getmetatable(section_t); + if not section_mt then + section_mt = {}; + setmetatable(section_t, section_mt); + end + local section_help = section_mt.help; + if not section_help then + section_help = { + desc = command.section_desc; + commands = {}; + }; + section_mt.help = section_help; + end + + section_help.commands[command.name] = { + desc = command.desc; + full = command.help; + args = array(command.args); + module = command._provided_by; + }; + + module:log("debug", "Shell command added by mod_%s: %s:%s()", mod_name, command.section, command.name); + end + + local function on_command_removed(event) + local command = event.item; + + local handler = event.item.handler; + if type(handler) ~= "function" or not schema.validate(command_metadata_schema, command) then + return; + end + + local section_t = def_env[command.section]; + if not section_t or section_t[command.name] ~= handler then + return; + end + + section_t[command.name] = nil; + if next(section_t) == nil then -- Delete section if empty + def_env[command.section] = nil; + end + + if command_host then + local host_command_info = host_commands[command.section..":"..command.name]; + if host_command_info then + -- Remove our host handler + host_command_info[command_host] = nil; + -- Clean up entire command entry if there are no per-host handlers left + local any_hosts = false; + for k in pairs(host_command_info) do + if k then -- metadata is false, ignore it + any_hosts = true; + break; + end + end + if not any_hosts then + host_commands[command.section..":"..command.name] = nil; + end + end + end + end + return on_command_added, on_command_removed; +end + +module:handle_items("shell-command", new_item_handlers()); + +function module.add_host(host_module) + host_module:handle_items("shell-command", new_item_handlers(host_module.host)); +end + +function module.unload() + stanza_watchers.cleanup(); +end ------------- |