view mod_sentry/sentry.lib.lua @ 5593:6d0574bfbf5d

mod_client_management: Include software version in table (when known) Showing software versions could be useful for statistical reasons, e.g. determining how quickly (or not) users upgrade, but most importantly for revoking vulnerable clients versions in case of a security issue.
author Kim Alvefur <zash@zash.se>
date Thu, 13 Jul 2023 23:26:02 +0200
parents cb3de818ff55
children
line wrap: on
line source

local array = require "util.array";
local hex = require "util.hex";
local random = require "util.random";
local url = require "socket.url";
local datetime = require "util.datetime".datetime;
local http = require 'net.http'
local json = require "util.json";
local errors = require "util.error";
local promise = require "util.promise";

local unpack = unpack or table.unpack -- luacheck: ignore

local user_agent = ("prosody-mod-%s/%s"):format((module.name:gsub("%W", "-")), (prosody.version:gsub("[^%w.-]", "-")));

local function generate_event_id()
	return hex.to(random.bytes(16));
end

local function get_endpoint(server, name)
	return ("%s/api/%d/%s/"):format(server.base_uri, server.project_id, name);
end

-- Parse a DSN string
-- https://develop.sentry.dev/sdk/overview/#parsing-the-dsn
local function parse_dsn(dsn_string)
	local parsed = url.parse(dsn_string);
	if not parsed then
		return nil, "unable to parse dsn (url)";
	end
	local path, project_id = parsed.path:match("^(.*)/(%d+)$");
	if not path then
		return nil, "unable to parse dsn (path)";
	end
	local base_uri = url.build({
		scheme = parsed.scheme;
		host = parsed.host;
		port = parsed.port;
		path = path;
	});
	return {
		base_uri = base_uri;
		public_key = parsed.user;
		project_id = project_id;
	};
end

local function get_error_data(instance_id, context)
	local data = {
		instance_id = instance_id;
	};
	for k, v in pairs(context) do
		if k ~= "traceback" then
			data[k] = tostring(v);
		end
	end
	return data;
end

local function error_to_sentry_exception(e)
	local exception = {
		type = e.condition or (e.code and tostring(e.code)) or nil;
		value = e.text or tostring(e);
		context = e.source;
		mechanism = {
			type = "generic";
			description = "Prosody error object";
			synthetic = not not e.context.wrapped_error;
			data = get_error_data(e.instance_id, e.context);
		};
	};
	local traceback = e.context.traceback;
	if traceback and type(traceback) == "table" then
		local frames = array();
		for i = #traceback, 1, -1 do
			local frame = traceback[i];
			table.insert(frames, {
				["function"] = frame.info.name;
				filename = frame.info.short_src;
				lineno = frame.info.currentline;
			});
		end
		exception.stacktrace = {
			frames = frames;
		};
	end
	return exception;
end

local sentry_event_methods = {};
local sentry_event_mt = { __index = sentry_event_methods };

function sentry_event_methods:set(key, value)
	self.event[key] = value;
	return self;
end

function sentry_event_methods:tag(tag_name, tag_value)
	local tags = self.event.tags;
	if not tags then
		tags = {};
		self.event.tags = tags;
	end
	if type(tag_name) == "string" then
		tags[tag_name] = tag_value;
	else
		for k, v in pairs(tag_name) do
			tags[k] = v;
		end
	end
	return self;
end

function sentry_event_methods:extra(key, value)
	local extra = self.event.extra;
	if not extra then
		extra = {};
		self.event.extra = extra;
	end
	if type(key) == "string" then
		extra[key] = tostring(value);
	else
		for k, v in pairs(key) do
			extra[k] = tostring(v);
		end
	end
	return self;
end

function sentry_event_methods:message(text)
	return self:set("message", { formatted = text });
end

function sentry_event_methods:add_exception(e)
	if errors.is_err(e) then
		if not self.event.message then
			if e.text then
				self:message(e.text);
			elseif type(e.context.wrapped_error) == "string" then
				self:message(e.context.wrapped_error);
			end
		end
		e = error_to_sentry_exception(e);
	elseif type(e) ~= "table" or not (e.type and e.value) then
		e = error_to_sentry_exception(errors.coerce(nil, e));
	end

	local exception = self.event.exception;
	if not exception or not exception.values then
		exception = { values = {} };
		self.event.exception = exception;
	end

	table.insert(exception.values, e);

	return self;
end

function sentry_event_methods:add_breadcrumb(crumb_timestamp, crumb_type, crumb_category, message, data)
	local crumbs = self.event.breadcrumbs;
	if not crumbs then
		crumbs = { values = {} };
		self.event.breadcrumbs = crumbs;
	end

	local crumb = {
		timestamp = crumb_timestamp and datetime(crumb_timestamp) or self.timestamp;
		type = crumb_type;
		category = crumb_category;
		message = message;
		data = data;
	};
	table.insert(crumbs.values, crumb);
	return self;
end

function sentry_event_methods:add_http_request_breadcrumb(http_request, message)
	local request_id_message = ("[Request %s]"):format(http_request.id);
	message = message and (request_id_message.." "..message) or request_id_message;
	return self:add_breadcrumb(http_request.time, "http", "net.http", message, {
		url = http_request.url;
		method = http_request.method or "GET";
		status_code = http_request.response and http_request.response.code or nil;
	});
end

function sentry_event_methods:set_request(http_request)
	return self:set("request", {
		method = http_request.method;
		url = url.build(http_request.url);
		headers = http_request.headers;
		env = {
			REMOTE_ADDR = http_request.ip;
		};
	});
end

function sentry_event_methods:send()
	return self.server:send(self.event);
end

local sentry_mt = { }
sentry_mt.__index = sentry_mt

local function new(conf)
	local server = assert(parse_dsn(conf.dsn));
	return setmetatable({
		server = server;
		endpoints = {
			store = get_endpoint(server, "store");
		};
		insecure = conf.insecure;
		tags = conf.tags or nil,
		extra = conf.extra or nil,
		server_name = conf.server_name or "undefined";
		logger = conf.logger;
	}, sentry_mt);
end

local function resolve_sentry_response(response)
	if response.code == 200 and response.body then
		local data = json.decode(response.body);
		return data;
	end
	module:log("warn", "Unexpected response from server: %d: %s", response.code, response.body);
	return promise.reject(response);
end

function sentry_mt:send(event)
	local json_payload = json.encode(event);
	local response_promise, err = self:_request(self.endpoints.store, "application/json", json_payload);

	if not response_promise then
		module:log("warn", "Failed to submit to Sentry: %s %s", err, json);
		return nil, err;
	end

	return response_promise:next(resolve_sentry_response), event.event_id;
end

function sentry_mt:_request(endpoint_url, body_type, body)
	local auth_header = ("Sentry sentry_version=7, sentry_client=%s, sentry_timestamp=%s, sentry_key=%s")
		:format(user_agent, datetime(), self.server.public_key);

	return http.request(endpoint_url, {
		headers = {
			["X-Sentry-Auth"] = auth_header;
			["Content-Type"] = body_type;
			["User-Agent"] = user_agent;
		};
		insecure = self.insecure;
		body = body;
	});
end

function sentry_mt:event(level, source)
	local event = setmetatable({
		server = self;
		event = {
			event_id = generate_event_id();
			timestamp = datetime();
			platform = "lua";
			server_name = self.server_name;
			logger = source or self.logger;
			level = level;
		};
	}, sentry_event_mt);
	if self.tags then
		event:tag(self.tags);
	end
	if self.extra then
		event:extra(self.extra);
	end
	return event;
end

return {
	new = new;
};