mod_http_admin_api: Ensure freshness of metrics when in manual mode
When in manual collection mode, as recommended for Prometheus,
collection needs to be triggered manually, or they would be stale,
possibly dating from the start of the server. This might vary per metric
depending on how and when the metrics are gathered.
local usermanager = require "core.usermanager";
local json = require "util.json";
local st = require "util.stanza";
local array = require "util.array";
local statsmanager = require "core.statsmanager";
module:depends("http");
local announce = module:depends("announce");
local invites = module:depends("invites");
local tokens = module:depends("tokenauth");
local mod_pep = module:depends("pep");
local mod_groups = module:depends("groups_internal");
local push_errors = module:shared("cloud_notify/push_errors");
local site_name = module:get_option_string("site_name", module.host);
local manual_stats_collection = module:context("*"):get_option("statistics_interval") == "manual";
local json_content_type = "application/json";
local www_authenticate_header = ("Bearer realm=%q"):format(module.host.."/"..module.name);
local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
local xmlns_nick = "http://jabber.org/protocol/nick";
local function check_credentials(request)
local auth_type, auth_data = string.match(request.headers.authorization or "", "^(%S+)%s(.+)$");
if not (auth_type and auth_data) then
return false;
end
if auth_type == "Bearer" then
local token_info = tokens.get_token_info(auth_data);
if not token_info or not token_info.session then
return false;
end
return token_info.session;
end
return nil;
end
function check_auth(routes)
local function check_request_auth(event)
local session = check_credentials(event.request);
if not session then
event.response.headers.authorization = www_authenticate_header;
return false, 401;
elseif session.auth_scope ~= "prosody:scope:admin" then
return false, 403;
end
event.session = session;
return true;
end
for route, handler in pairs(routes) do
routes[route] = function (event, ...)
local permit, code = check_request_auth(event);
if not permit then
return code;
end
return handler(event, ...);
end;
end
return routes;
end
local function token_info_to_invite_info(token_info)
local additional_data = token_info.additional_data;
local groups = additional_data and additional_data.groups or nil;
local source = additional_data and additional_data.source or nil;
local reset = not not (additional_data and additional_data.allow_reset or nil);
return {
id = token_info.token;
type = token_info.type;
reusable = not not token_info.reusable;
inviter = token_info.inviter;
jid = token_info.jid;
uri = token_info.uri;
landing_page = token_info.landing_page;
created_at = token_info.created_at;
expires = token_info.expires;
groups = groups;
source = source;
reset = reset;
};
end
function list_invites(event)
local invites_list = {};
for token, invite in invites.pending_account_invites() do --luacheck: ignore 213/token
table.insert(invites_list, token_info_to_invite_info(invite));
end
table.sort(invites_list, function (a, b)
return a.created_at < b.created_at;
end);
event.response.headers["Content-Type"] = json_content_type;
return json.encode_array(invites_list);
end
function get_invite_by_id(event, invite_id)
local invite = invites.get_account_invite_info(invite_id);
if not invite then
return 404;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode(token_info_to_invite_info(invite));
end
function create_invite_type(event, invite_type)
local options;
local request = event.request;
if request.body and #request.body > 0 then
if request.headers.content_type ~= json_content_type then
module:log("warn", "Invalid content type");
return 400;
end
options = json.decode(event.request.body);
if not options then
module:log("warn", "Invalid JSON");
return 400;
end
else
options = {};
end
local source = event.session.username .. "@" .. module.host .. "/admin_api";
local invite;
if invite_type == "reset" then
if not options.username then
return 400;
end
invite = invites.create_account_reset(options.username, options.ttl);
elseif invite_type == "group" then
if not options.groups then
return 400;
end
invite = invites.create_group(options.groups, {
source = source;
}, options.ttl);
elseif invite_type == "account" then
invite = invites.create_account(options.username, {
source = source;
groups = options.groups;
}, options.ttl);
else
return 400;
end
if not invite then
return 500;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode(token_info_to_invite_info(invite));
end
function delete_invite(event, invite_id) --luacheck: ignore 212/event
if not invites.delete_account_invite(invite_id) then
return 404;
end
return 200;
end
local function get_user_info(username)
if not usermanager.user_exists(username, module.host) then
return nil;
end
local display_name;
do
local pep_service = mod_pep.get_pep_service(username);
local ok, _, nick_item = pep_service:get_last_item(xmlns_nick, true);
if ok and nick_item then
display_name = nick_item:get_child_text("nick", xmlns_nick);
end
end
local roles = nil;
if usermanager.get_roles then
local roles_map = usermanager.get_roles(username.."@"..module.host, module.host)
roles = array()
if roles_map then
for role in pairs(roles_map) do
roles:push(role)
end
end
end
return {
username = username;
display_name = display_name;
roles = roles;
};
end
local function get_session_debug_info(session)
local info = {
full_jid = session.full_jid;
ip = session.ip;
since = math.floor(session.conntime);
status = {
connected = not not session.conn;
hibernating = not not session.hibernating;
};
features = {
carbons = not not session.want_carbons;
encrypted = not not session.secure;
acks = not not session.smacks;
resumption = not not session.resumption_token;
mobile_optimization = not not session.csi_counter;
push_notifications = not not session.push_identifier;
history = not not session.mam_requested;
};
queues = {};
};
-- CSI
if session.state then
info.status.active = session.state == "active";
info.queues.held_stanzas = session.csi_counter or 0;
end
-- Smacks queue
if session.last_requested_h and session.last_acknowledged_stanza then
info.queues.awaiting_acks = session.last_requested_h - session.last_acknowledged_stanza;
end
if session.push_identifier then
info.push_info = {
id = session.push_identifier;
wakeup_push_sent = session.first_hibernated_push;
};
end
return info;
end
local function get_user_omemo_info(username)
local everything_valid = true;
local any_device = false;
local omemo_status = {};
local omemo_devices;
local pep_service = mod_pep.get_pep_service(username);
if pep_service and pep_service.nodes then
local ok, _, device_list = pep_service:get_last_item("eu.siacs.conversations.axolotl.devicelist", true);
if ok and device_list then
device_list = device_list:get_child("list", "eu.siacs.conversations.axolotl");
end
if device_list then
omemo_devices = {};
for device_entry in device_list:childtags("device") do
any_device = true;
local device_info = {};
local device_id = tonumber(device_entry.attr.id or "");
if device_id then
device_info.id = device_id;
local bundle_id = ("eu.siacs.conversations.axolotl.bundles:%d"):format(device_id);
local have_bundle, _, bundle = pep_service:get_last_item(bundle_id, true);
if have_bundle and bundle and bundle:get_child("bundle", "eu.siacs.conversations.axolotl") then
device_info.have_bundle = true;
local config_ok, bundle_config = pep_service:get_node_config(bundle_id, true);
if config_ok and bundle_config then
device_info.bundle_config = bundle_config;
if bundle_config.max_items == 1
and bundle_config.access_model == "open"
and bundle_config.persist_items == true
and bundle_config.publish_model == "publishers" then
device_info.valid = true;
end
end
end
end
if device_info.valid == nil then
device_info.valid = false;
everything_valid = false;
end
table.insert(omemo_devices, device_info);
end
local config_ok, list_config = pep_service:get_node_config("eu.siacs.conversations.axolotl.devicelist", true);
if config_ok and list_config then
omemo_status.config = list_config;
if list_config.max_items == 1
and list_config.access_model == "open"
and list_config.persist_items == true
and list_config.publish_model == "publishers" then
omemo_status.config_valid = true;
end
end
if omemo_status.config_valid == nil then
omemo_status.config_valid = false;
everything_valid = false;
end
end
end
omemo_status.valid = everything_valid and any_device;
return {
status = omemo_status;
devices = omemo_devices;
};
end
local function get_user_debug_info(username)
local debug_info = {
time = os.time();
};
-- Online sessions
do
local user_sessions = hosts[module.host].sessions[username];
if user_sessions then
user_sessions = user_sessions.sessions
end
local sessions = {};
if user_sessions then
for _, session in pairs(user_sessions) do
table.insert(sessions, get_session_debug_info(session));
end
end
debug_info.sessions = sessions;
end
-- Push registrations
do
local store = module:open_store("cloud_notify");
local services = store:get(username);
local push_registrations = {};
if services then
for identifier, push_info in pairs(services) do
push_registrations[identifier] = {
since = push_info.timestamp;
service = push_info.jid;
node = push_info.node;
error_count = push_errors[identifier] or 0;
client = push_info.client;
};
end
end
debug_info.push_registrations = push_registrations;
end
-- OMEMO
debug_info.omemo = get_user_omemo_info(username);
return debug_info;
end
function list_users(event)
local user_list = {};
for username in usermanager.users(module.host) do
table.insert(user_list, get_user_info(username));
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode_array(user_list);
end
function get_user_by_name(event, username)
local property
do
local name, sub_path = username:match("^([^/]+)/(%w+)$");
if name then
username = name;
property = sub_path;
end
end
if property == "groups" then
event.response.headers["Content-Type"] = json_content_type;
return json.encode(mod_groups.get_user_groups(username));
elseif property == "debug" then
event.response.headers["Content-Type"] = json_content_type;
return json.encode(get_user_debug_info(username));
end
local user_info = get_user_info(username);
if not user_info then
return 404;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode(user_info);
end
function update_user(event, username)
local current_user = get_user_info(username);
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
return 400;
end
local new_user = json.decode(event.request.body);
if not new_user then
return 400;
end
if new_user.username and new_user.username ~= username then
return 400;
end
local final_user = {};
if new_user.display_name then
local pep_service = mod_pep.get_pep_service(username);
-- TODO: publish
local nick_item = st.stanza("item", { xmlns = xmlns_pubsub, id = "current" })
:text_tag("nick", new_user.display_name, { xmlns = xmlns_nick });
if pep_service:publish(xmlns_nick, true, "current", nick_item, {
access_model = "open";
_defaults_only = true;
}) then
final_user.display_name = new_user.display_name;
end
end
if new_user.roles then
if not usermanager.set_roles then
return 500, "feature-not-implemented"
end
local backend_roles = {};
for _, role in ipairs(new_user.roles) do
backend_roles[role] = true;
end
local jid = username.."@"..module.host;
if not usermanager.set_roles(jid, module.host, backend_roles) then
module:log("error", "failed to set roles %q for %s", backend_roles, jid)
return 500
end
end
return 200;
end
function delete_user(event, username) --luacheck: ignore 212/event
if not usermanager.delete_user(username, module.host) then
return 404;
end
return 200;
end
function list_groups(event)
local group_list = {};
for group_id in mod_groups.groups() do
local group_info = mod_groups.get_info(group_id);
table.insert(group_list, {
id = group_id;
name = group_info.name;
muc_jid = group_info.muc_jid;
members = mod_groups.get_members(group_id);
});
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode_array(group_list);
end
function get_group_by_id(event, group_id)
local group = mod_groups.get_info(group_id);
if not group then
return 404;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode({
id = group_id;
name = group.name;
muc_jid = group.muc_jid;
members = mod_groups.get_members(group_id);
});
end
function create_group(event)
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
return 400;
end
local group = json.decode(event.request.body);
if not group then
return 400;
end
if not group.name then
module:log("warn", "Group missing name property");
return 400;
end
local create_muc = group.create_muc and true or false;
local group_id = mod_groups.create(
{
name = group.name;
},
create_muc
);
if not group_id then
return 500;
end
event.response.headers["Content-Type"] = json_content_type;
local info = mod_groups.get_info(group_id);
return json.encode({
id = group_id;
name = info.name;
muc_jid = info.muc_jid or nil;
members = {};
});
end
function update_group(event, group) --luacheck: ignore 212/event
-- Add member
local group_id, member_name = group:match("^([^/]+)/members/([^/]+)$");
if group_id and member_name then
if not mod_groups.add_member(group_id, member_name) then
return 500;
end
return 204;
end
local group_id = group:match("^([^/]+)$")
if group_id then
local request = event.request;
if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then
return 400;
end
local update = json.decode(event.request.body);
if not update then
return 400;
end
local group_info = mod_groups.get_info(group_id);
if not group_info then
return 404;
end
if update.name then
group_info["name"] = update.name;
end
if mod_groups.set_info(group_id, group_info) then
return 204;
else
return 500;
end
end
return 404;
end
function delete_group(event, subpath) --luacheck: ignore 212/event
-- Check if this is a membership deletion and handle it
local group_id, member_name = subpath:match("^([^/]+)/members/([^/]+)$");
if group_id and member_name then
if mod_groups.remove_member(group_id, member_name) then
return 204;
else
return 500;
end
else
-- Action refers to the group
group_id = subpath;
end
if not group_id then
return 400;
end
if not mod_groups.exists(group_id) then
return 404;
end
if not mod_groups.delete(group_id) then
return 500;
end
return 204;
end
local function get_server_info(event)
event.response.headers["Content-Type"] = json_content_type;
return json.encode({
site_name = site_name;
version = prosody.version;
});
end
local function maybe_export_plain_gauge(mf)
if mf == nil then
return nil
end
return mf.data.value
end
local function maybe_export_plain_counter(mf)
if mf == nil then
return nil
end
return {
since = mf.data._created,
value = mf.data.value,
}
end
local function maybe_export_summed_gauge(mf)
if mf == nil then
return nil
end
local sum = 0;
for _, metric in mf:iter_metrics() do
sum = sum + metric.value;
end
return sum;
end
local function get_server_metrics(event)
event.response.headers["Content-Type"] = json_content_type;
local result = {};
if manual_stats_collection then
statsmanager.collect();
end
local families = statsmanager.get_metric_registry():get_metric_families();
result.memory = maybe_export_plain_gauge(families.process_resident_memory_bytes);
result.cpu = maybe_export_plain_counter(families.process_cpu_seconds);
result.c2s = maybe_export_summed_gauge(families["prosody_mod_c2s/connections"])
return json.encode(result);
end
local function post_server_announcement(event)
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
return 400;
end
local body = json.decode(event.request.body);
if not body then
return 400;
end
if type(body.recipients) ~= "table" and body.recipients ~= "online" and body.recipients ~= "all" then
return 400;
end
if not body.body or #body.body == 0 then
return 400;
end
local message = st.message():tag("body"):text(body.body):up();
local host = module.host
message.attr.from = host
if body.recipients == "online" then
announce.send_to_online(message, host);
elseif body.recipients == "all" then
for username in usermanager.users(host) do
message.attr.to = username .. "@" .. host
module:send(st.clone(message))
end
else
for _, addr in ipairs(body.recipients) do
message.attr.to = addr
module:send(message)
end
end
return 201;
end
module:provides("http", {
route = check_auth {
["GET /invites"] = list_invites;
["GET /invites/*"] = get_invite_by_id;
["POST /invites/*"] = create_invite_type;
["DELETE /invites/*"] = delete_invite;
["GET /users"] = list_users;
["GET /users/*"] = get_user_by_name;
["PUT /users/*"] = update_user;
["DELETE /users/*"] = delete_user;
["GET /groups"] = list_groups;
["GET /groups/*"] = get_group_by_id;
["POST /groups"] = create_group;
["PUT /groups/*"] = update_group;
["DELETE /groups/*"] = delete_group;
["GET /server/info"] = get_server_info;
["GET /server/metrics"] = get_server_metrics;
["POST /server/announcement"] = post_server_announcement;
};
});