prosodyctl
author Kim Alvefur <zash@zash.se>
Sun, 24 Mar 2024 21:32:00 +0100
changeset 13468 2dbc169aae6a
parent 13171 6226f75f55a7
permissions -rwxr-xr-x
util.startup: Abort before initialization of logging when started as root Prevents creation of log files owned by the root user which could be inaccessible once started correctly.

#!/usr/bin/env lua
-- 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.
--

-- prosodyctl - command-line controller for Prosody XMPP server

-- Will be modified by configure script if run --
CFG_SOURCEDIR=CFG_SOURCEDIR or os.getenv("PROSODY_SRCDIR");
CFG_CONFIGDIR=CFG_CONFIGDIR or os.getenv("PROSODY_CFGDIR");
CFG_PLUGINDIR=CFG_PLUGINDIR or os.getenv("PROSODY_PLUGINDIR");
CFG_DATADIR=CFG_DATADIR or os.getenv("PROSODY_DATADIR");

-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --

local function is_relative(path)
	local path_sep = package.config:sub(1,1);
	return ((path_sep == "/" and path:sub(1,1) ~= "/")
		or (path_sep == "\\" and (path:sub(1,1) ~= "/" and path:sub(2,3) ~= ":\\")))
end

-- Tell Lua where to find our libraries
if CFG_SOURCEDIR then
	local function filter_relative_paths(path)
		if is_relative(path) then return ""; end
	end
	local function sanitise_paths(paths)
		return (paths:gsub("[^;]+;?", filter_relative_paths):gsub(";;+", ";"));
	end
	package.path = sanitise_paths(CFG_SOURCEDIR.."/?.lua;"..package.path);
	package.cpath = sanitise_paths(CFG_SOURCEDIR.."/?.so;"..package.cpath);
end

-- Substitute ~ with path to home directory in data path
if CFG_DATADIR then
	if os.getenv("HOME") then
		CFG_DATADIR = CFG_DATADIR:gsub("^~", os.getenv("HOME"));
	end
end

-----------

-- Check before first require, to preempt the probable failure
if _VERSION < "Lua 5.2" then
	io.stderr:write("Prosody is no longer compatible with Lua 5.1\n")
	io.stderr:write("See https://prosody.im/doc/depends#lua for more information\n")
	return os.exit(1);
end

if not pcall(require, "prosody.loader") then
	pcall(require, "loader");
end

local startup = require "prosody.util.startup";
startup.prosodyctl();

-----------

local configmanager = require "prosody.core.configmanager";
local modulemanager = require "prosody.core.modulemanager"
local prosodyctl = require "prosody.util.prosodyctl"
local socket = require "socket"
local dependencies = require "prosody.util.dependencies";
local lfs = dependencies.softreq "lfs";

-----------------------

local parse_args = require "prosody.util.argparse".parse;
local human_io = require "prosody.util.human.io";

local show_message, show_warning = prosodyctl.show_message, prosodyctl.show_warning;
local show_usage = prosodyctl.show_usage;
local read_password = human_io.read_password;
local call_luarocks = prosodyctl.call_luarocks;
local error_messages = prosodyctl.error_messages;

local jid_split = require "prosody.util.jid".prepped_split;

local prosodyctl_timeout = (configmanager.get("*", "prosodyctl_timeout") or 5) * 2;
-----------------------
local commands = {};
local command = table.remove(arg, 1);

local only_help = { short_params = { h = "help"; ["?"] = "help" } }

function commands.install(arg)
	local opts = parse_args(arg, only_help);
	if opts.help or not arg[1] then
		show_usage([[install]], [[Installs a prosody/luarocks plugin]]);
		return opts.help and 0 or 1;
	end
	-- TODO finalize config option name
	local server = opts.server or configmanager.get("*", "plugin_server");
	if not (arg[1]:match("^https://") or lfs.attributes(arg[1]) or server) then
		show_warning("There is no 'plugin_server' option in the configuration file");
		-- see https://prosody.im/doc/TODO documentation
		-- #1602
		return 1;
	end
	show_message("Installing %s in %s", arg[1], prosody.paths.installer);
	local ret = call_luarocks("install", arg[1], server);
	if ret == 0 and arg[1]:match("^mod_") then
		prosodyctl.show_module_configuration_help(arg[1]);
	end
	return ret;
end

function commands.remove(arg)
	local opts = parse_args(arg, only_help);
	if opts.help or not arg[1] then
		show_usage([[remove]], [[Removes a module installed in the working directory's plugins folder]]);
		return opts.help and 0 or 1;
	end
	show_message("Removing %s from %s", arg[1], prosody.paths.installer);
	local ret = call_luarocks("remove", arg[1]);
	return ret;
end

function commands.list(arg)
	local opts = parse_args(arg, only_help);
	if opts.help then
		show_usage([[list]], [[Shows installed rocks]]);
		return 0;
	end
	local server = opts.server or configmanager.get("*", "plugin_server");
	if opts.outdated then
		-- put this back for luarocks
		arg[1] = "--outdated";

		if not server then
			show_warning("There is no 'plugin_server' option in the configuration file, but this is needed for 'list --outdated' to work.");
			return 1;
		end
	end
	local ret = call_luarocks("list", arg[1], server);
	return ret;
end

function commands.adduser(arg)
	local opts = parse_args(arg, only_help);
	if opts.help or not arg[1] then
		show_usage([[adduser JID]], [[Create the specified user account in Prosody]]);
		return opts.help and 0 or 1;
	end
	local user, host = jid_split(arg[1]);
	if not user and host then
		show_message [[Failed to understand JID, please supply the JID you want to create]]
		show_usage [[adduser user@host]]
		return 1;
	end

	if not host then
		show_message [[Please specify a JID, including a host. e.g. alice@example.com]];
		return 1;
	end

	if not prosody.hosts[host] then
		show_warning("The host '%s' is not listed in the configuration file (or is not enabled).", host)
		show_warning("The user will not be able to log in until this is changed.");
		prosody.hosts[host] = startup.make_host(host); --luacheck: ignore 122
	end

	if prosodyctl.user_exists{ user = user, host = host } then
		show_message [[That user already exists]];
		return 1;
	end

	local password = read_password();
	if not password then return 1; end

	local ok, msg = prosodyctl.adduser { user = user, host = host, password = password };

	if ok then return 0; end

	show_message(error_messages[msg])
	return 1;
end

function commands.passwd(arg)
	local opts = parse_args(arg, only_help);
	if opts.help or not arg[1] then
		show_usage([[passwd JID]], [[Set the password for the specified user account in Prosody]]);
		return opts.help and 0 or 1;
	end
	local user, host = jid_split(arg[1]);
	if not user and host then
		show_message [[Failed to understand JID, please supply the JID you want to set the password for]]
		show_usage [[passwd user@host]]
		return 1;
	end

	if not host then
		show_message [[Please specify a JID, including a host. e.g. alice@example.com]];
		return 1;
	end

	if not prosody.hosts[host] then
		show_warning("The host '%s' is not listed in the configuration file (or is not enabled).", host)
		show_warning("The user will not be able to log in until this is changed.");
		prosody.hosts[host] = startup.make_host(host); --luacheck: ignore 122
	end

	if not prosodyctl.user_exists { user = user, host = host } then
		show_message [[That user does not exist, use prosodyctl adduser to create a new user]]
		return 1;
	end

	local password = read_password();
	if not password then return 1; end

	local ok, msg = prosodyctl.passwd { user = user, host = host, password = password };

	if ok then return 0; end

	show_message(error_messages[msg])
	return 1;
end

function commands.deluser(arg)
	local opts = parse_args(arg, only_help);
	if opts.help or not arg[1] then
		show_usage([[deluser JID]], [[Permanently remove the specified user account from Prosody]]);
		return opts.help and 0 or 1;
	end
	local user, host = jid_split(arg[1]);
	if not user and host then
		show_message [[Failed to understand JID, please supply the JID to the user account you want to delete]]
		show_usage [[deluser user@host]]
		return 1;
	end

	if not host then
		show_message [[Please specify a JID, including a host. e.g. alice@example.com]];
		return 1;
	end

	if not prosody.hosts[host] then
		show_warning("The host '%s' is not listed in the configuration file (or is not enabled).", host)
		prosody.hosts[host] = startup.make_host(host); --luacheck: ignore 122
	end

	if not prosodyctl.user_exists { user = user, host = host } then
		show_message [[That user does not exist on this server]]
		return 1;
	end

	local ok, msg = prosodyctl.deluser { user = user, host = host };

	if ok then return 0; end

	show_message(error_messages[msg])
	return 1;
end

local function has_init_system() --> which
	lfs = lfs or require"lfs";
	if lfs.attributes("/etc/systemd") then
		return "systemd";
	elseif lfs.attributes("/etc/init.d/prosody") then
		return "rc.d";
	end
end

local function service_command_warning(service_command)
	if prosody.installed and configmanager.get("*", "prosodyctl_service_warnings") ~= false then
		show_warning("WARNING: Use of prosodyctl start/stop/restart/reload is not recommended");
		show_warning("         if Prosody is managed by an init system - use that directly instead.");
		local init = has_init_system()
		if init == "systemd" then
			show_warning("         e.g. systemctl %s prosody", service_command);
		elseif init == "rc.d" then
			show_warning("         e.g. /etc/init.d/prosody %s", service_command);
		end
		show_warning("");
	end
end

function commands.start(arg)
	local opts = parse_args(arg, only_help);
	if opts.help then
		show_usage([[start]], [[Start Prosody]]);
		return 0;
	end
	service_command_warning("start");
	local ok, ret = prosodyctl.isrunning();
	if not ok then
		show_message(error_messages[ret]);
		return 1;
	end

	if ret then
		--luacheck: ignore 421/ret
		local ok, ret = prosodyctl.getpid();
		if not ok then
			show_message("Couldn't get running Prosody's PID");
			show_message(error_messages[ret]);
			return 1;
		end
		show_message("Prosody is already running with PID %s", ret or "(unknown)");
		return 1;
	end

	--luacheck: ignore 411/ret
	local lua;
	do
		local i = 0;
		repeat
			i = i - 1;
		until arg[i-1] == nil
		lua = arg[i];
	end
	local ok, ret = prosodyctl.start(prosody.paths.source, lua);
	if ok then
		local daemonize = configmanager.get("*", "daemonize");
		if daemonize == nil then
			daemonize = prosody.installed;
		end
		if daemonize then
			local i=1;
			while true do
				local ok, running = prosodyctl.isrunning();
				if ok and running then
					break;
				elseif i == 5 then
					show_message("Still waiting...");
				elseif i >= prosodyctl_timeout then
					show_message("Prosody is still not running. Please give it some time or check your log files for errors.");
					return 2;
				end
				socket.sleep(0.5);
				i = i + 1;
			end
			show_message("Started");
		end
		return 0;
	end

	show_message("Failed to start Prosody");
	show_message(error_messages[ret])
	return 1;
end

function commands.status(arg)
	local opts = parse_args(arg, only_help);
	if opts.help then
		show_usage([[status]], [[Reports the running status of Prosody]]);
		return 0;
	end

	local ok, ret = prosodyctl.isrunning();
	if not ok then
		show_message(error_messages[ret]);
		return 1;
	end

	if ret then
		--luacheck: ignore 421/ret
		local ok, ret = prosodyctl.getpid();
		if not ok then
			show_message("Couldn't get running Prosody's PID");
			show_message(error_messages[ret]);
			return 1;
		end
		show_message("Prosody is running with PID %s", ret or "(unknown)");
		return 0;
	else
		show_message("Prosody is not running");
		if not prosody.switched_user and prosody.current_uid ~= 0 then
			print("\nNote:")
			print(" You will also see this if prosodyctl is not running under");
			print(" the same user account as Prosody. Try running as root (e.g. ");
			print(" with 'sudo' in front) to gain access to Prosody's real status.");
		end
		return 2
	end
end

function commands.stop(arg)
	local opts = parse_args(arg, only_help);
	if opts.help then
		show_usage([[stop]], [[Stop a running Prosody server]]);
		return 0;
	end

	service_command_warning("stop");

	if not prosodyctl.isrunning() then
		show_message("Prosody is not running");
		return 1;
	end

	local ok, ret = prosodyctl.stop();
	if ok then
		local i=1;
		while true do
			local ok, running = prosodyctl.isrunning();
			if ok and not running then
				break;
			elseif i == 5 then
				show_message("Still waiting...");
			elseif i >= prosodyctl_timeout then
				show_message("Prosody is still running. Please give it some time or check your log files for errors.");
				return 2;
			end
			socket.sleep(0.5);
			i = i + 1;
		end
		show_message("Stopped");
		return 0;
	end

	show_message(error_messages[ret]);
	return 1;
end

function commands.restart(arg)
	local opts = parse_args(arg, only_help);
	if opts.help then
		show_usage([[restart]], [[Restart a running Prosody server]]);
		return 1;
	end

	service_command_warning("restart");

	commands.stop(arg);
	return commands.start(arg);
end

function commands.about(arg)
	local opts = parse_args(arg, only_help);
	if opts.help then
		show_usage([[about]], [[Show information about this Prosody installation]]);
		return 0;
	end

	local pwd = ".";
	local sorted_pairs = require "prosody.util.iterators".sorted_pairs;
	local hg = require"prosody.util.mercurial";
	local relpath = configmanager.resolve_relative_path;

	print("Prosody "..(prosody.version or "(unknown version)"));
	print("");
	print("# Prosody directories");
	print("Data directory:     "..relpath(pwd, prosody.paths.data));
	print("Config directory:   "..relpath(pwd, prosody.paths.config or "."));
	print("Source directory:   "..relpath(pwd, prosody.paths.source or "."));
	print("Plugin directories:")
	print("  "..(prosody.paths.plugins:gsub("([^;]+);?", function(path)
			path = configmanager.resolve_relative_path(pwd, path);
			local hgid, hgrepo = hg.check_id(path);
			if not hgid and hgrepo then
				return path.." - "..hgrepo .."!\n  ";
			end
			-- 010452cfaf53 is the first commit in the prosody-modules repository
			hgrepo = hgrepo == "010452cfaf53" and "prosody-modules";
			return path..(hgid and " - "..(hgrepo or "HG").." rev: "..hgid or "")
				.."\n  ";
		end)));
	print("");
	local have_pposix, pposix = pcall(require, "prosody.util.pposix");
	if have_pposix and pposix.uname then
		print("# Operating system");
		local uname, err = pposix.uname();
		print(uname and uname.sysname .. " " .. uname.release or "Unknown POSIX", err or "");
		print("");
	end
	print("# Lua environment");
	print("Lua version:             ", _G._VERSION);
	print("");
	print("Lua module search paths:");
	for path in package.path:gmatch("[^;]+") do
		print("  "..path);
	end
	print("");
	print("Lua C module search paths:");
	for path in package.cpath:gmatch("[^;]+") do
		print("  "..path);
	end
	print("");
	local luarocks_status = "Not installed"
	if pcall(require, "luarocks.loader") then
		luarocks_status = "Installed (2.x+)";
		if package.loaded["luarocks.cfg"] then
			luarocks_status = "Installed ("..(package.loaded["luarocks.cfg"].program_version or "2.x+")..")";
		end
	elseif pcall(require, "luarocks.require") then
		luarocks_status = "Installed (1.x)";
	end
	print("LuaRocks:        ", luarocks_status);
	print("");
	print("# Network");
	print("");
	print("Backend: "..require "prosody.net.server".get_backend());
	print("");
	print("# Lua module versions");
	local module_versions, longest_name = {}, 8;
	local library_versions = {};
	dependencies.softreq"ssl";
	dependencies.softreq"DBI";
	dependencies.softreq"readline";
	local friendly_names = {
		DBI = "LuaDBI";
		lfs = "LuaFileSystem";
		lunbound = "luaunbound";
		lxp = "LuaExpat";
		socket = "LuaSocket";
		ssl = "LuaSec";
	};
	local alternate_version_fields = {
		-- These diverge from the module._VERSION convention
		readline = "Version";
	}
	local lunbound = dependencies.softreq"lunbound";
	local lxp = dependencies.softreq"lxp";
	local hashes = dependencies.softreq"prosody.util.hashes";
	for name, module in pairs(package.loaded) do
		local version_field = alternate_version_fields[name] or "_VERSION";
		if type(module) == "table" and rawget(module, version_field)
			and name ~= "_G" and not name:match("%.") then
			name = friendly_names[name] or name;
			if #name > longest_name then
				longest_name = #name;
			end
			local mod_version = module[version_field];
			if tostring(mod_version):sub(1, #name+1) == name .. " " then
				mod_version = mod_version:sub(#name+2);
			end
			module_versions[name] = mod_version;
		end
	end
	if lunbound then
		if not module_versions["luaunbound"] then
			module_versions["luaunbound"] = "0.5 (?)";
		end
		library_versions["libunbound"] = lunbound._LIBVER;
	end
	if lxp then
		library_versions["libexpat"] = lxp._EXPAT_VERSION;
	end
	if hashes then
		library_versions["libcrypto"] = hashes._LIBCRYPTO_VERSION;
	end
	for name, version in sorted_pairs(module_versions) do
		print(name..":"..string.rep(" ", longest_name-#name), version);
	end
	print("");
	print("# library versions");
	if require "prosody.net.server".event_base then
		library_versions["libevent"] = require"luaevent".core.libevent_version();
	end
	for name, version in sorted_pairs(library_versions) do
		print(name..":"..string.rep(" ", longest_name-#name), version);
	end
	print("");
end

function commands.reload(arg)
	local opts = parse_args(arg, only_help);
	if opts.help then
		show_usage([[reload]], [[Reload Prosody's configuration and re-open log files]]);
		return 0;
	end

	if arg[1] and arg[1]:match"^mod_" then
		-- TODO reword the usage text, document
		local shell = require "prosody.util.prosodyctl.shell";
		arg[1] = arg[1]:match("^mod_(.*)"); -- strip mod_ prefix
		table.insert(arg, 1, "module");
		table.insert(arg, 2, "reload");
		return shell.shell(arg);
	end

	service_command_warning("reload");

	if not prosodyctl.isrunning() then
		show_message("Prosody is not running");
		return 1;
	end

	local ok, ret = prosodyctl.reload();
	if ok then

		show_message("Prosody log files re-opened and config file reloaded. You may need to reload modules for some changes to take effect.");
		return 0;
	end

	show_message(error_messages[ret]);
	return 1;
end
-- ejabberdctl compatibility

local unpack = table.unpack;

function commands.register(arg)
	local user, host, password = unpack(arg);
	if (not (user and host)) or arg[1] == "--help" then
		if user ~= "--help" then
			if not user then
				show_message [[No username specified]]
			elseif not host then
				show_message [[Please specify which host you want to register the user on]];
			end
		end
		show_usage("register USER HOST [PASSWORD]", "Register a user on the server, with the given password");
		return 1;
	end
	if not password then
		password = read_password();
		if not password then
			show_message [[Unable to register user with no password]];
			return 1;
		end
	end

	local ok, msg = prosodyctl.adduser { user = user, host = host, password = password };

	if ok then return 0; end

	show_message(error_messages[msg])
	return 1;
end

function commands.unregister(arg)
	local user, host = unpack(arg);
	if (not (user and host)) or arg[1] == "--help" then
		if user ~= "--help" then
			if not user then
				show_message [[No username specified]]
			elseif not host then
				show_message [[Please specify which host you want to unregister the user from]];
			end
		end
		show_usage("unregister USER HOST [PASSWORD]", "Permanently remove a user account from the server");
		return 1;
	end

	local ok, msg = prosodyctl.deluser { user = user, host = host };

	if ok then return 0; end

	show_message(error_messages[msg])
	return 1;
end

---------------------

local async = require "prosody.util.async";
local server = require "prosody.net.server";
local watchers = {
	error = function (_, err)
		error(err);
	end;
	waiting = function ()
		server.loop();
	end;
};
local command_runner = async.runner(function ()
	if command and command:match("^mod_") then -- Is a command in a module
		local module_name = command:match("^mod_(.+)");
		do
			local ret, err = modulemanager.load("*", module_name);
			if not ret then
				show_message("Failed to load module '"..module_name.."': "..err);
				os.exit(1);
			end
		end

		local module = modulemanager.get_module("*", module_name);
		if not module then
			show_message("Failed to load module '"..module_name.."': Unknown error");
			os.exit(1);
		end

		if not modulemanager.module_has_method(module, "command") then
			show_message("Fail: mod_"..module_name.." does not support any commands");
			os.exit(1);
		end

		local ok, ret = modulemanager.call_module_method(module, "command", arg);
		if ok then
			if type(ret) == "number" then
				os.exit(ret, true);
			elseif type(ret) == "string" then
				show_message(ret);
			end
			os.exit(0, true); -- :)
		else
			show_message("Failed to execute command: %s", error_messages[ret]);
			os.exit(1); -- :(
		end
	end

	if command and not commands[command] then
		local ok, command_module = pcall(require, "prosody.util.prosodyctl."..command);
		if ok and command_module[command] then
			commands[command] = command_module[command];
		end
	end

	if not commands[command] then -- Show help for all commands
		function show_usage(usage, desc)
			print(string.format(" %-11s    %s", usage, desc));
		end

		print("prosodyctl - Manage a Prosody server");
		print("");
		print("Usage: "..arg[0].." COMMAND [OPTIONS]");
		print("");
		print("Where COMMAND may be one of:");

		local hidden_commands = require "prosody.util.set".new{ "register", "unregister" };
		local commands_order = {
			"Process management:",
				"start"; "stop"; "restart"; "reload"; "status";
				"shell",
			"User management:",
				"adduser"; "passwd"; "deluser";
			"Plugin management:",
				"install"; "remove"; "list";
			"Informative:",
				"about",
				"check",
			"Other:",
				"cert",
		};
		-- These live in util.prosodyctl.$command so we have their short help here.
		local external_commands = {
			cert = "Certificate management commands",
			check = "Perform basic checks on your Prosody installation",
			shell = "Interact with a running Prosody",
		}

		local done = {};

		if prosody.installed and has_init_system() then
			-- Hide start, stop, restart
			done[table.remove(commands_order, 2)] = true;
			done[table.remove(commands_order, 2)] = true;
			done[table.remove(commands_order, 2)] = true;
		end

		for _, command_name in ipairs(commands_order) do
			local command_func = commands[command_name];
			if command_func then
				command_func{ "--help" };
				done[command_name] = true;
			elseif external_commands[command_name] then
				show_usage(command_name, external_commands[command_name]);
				done[command_name] = true;
			else
				print""
				print(command_name);
			end
		end

		for command_name, command_func in pairs(commands) do
			if not done[command_name] and not hidden_commands:contains(command_name) then
				command_func{ "--help" };
				done[command_name] = true;
			end
		end


		os.exit(0, true);
	end

	os.exit(commands[command](arg), true);
end, watchers);

command_runner:run(true);