view mod_incidents_handling/incidents_handling/mod_incidents_handling.lua @ 4876:0f5f2d4475b9

mod_http_xep227: Add support for import via APIs rather than direct store manipulation In particular this transitions PEP nodes and data to be imported via mod_pep's APIs, fixing issues with importing at runtime while PEP data may already be live in RAM. Next obvious candidate for this approach is rosters, so clients get immediate roster pushes and other special handling (such as emitting subscribes to reach the desired subscription state).
author Matthew Wild <mwild1@gmail.com>
date Tue, 18 Jan 2022 17:01:18 +0000
parents 7dbde05b48a9
children
line wrap: on
line source

-- This plugin implements XEP-268 (Incidents Handling)
-- (C) 2012-2013, Marco Cirillo (LW.Org)

-- Note: Only part of the IODEF specifications are supported.

module:depends("adhoc")

local datamanager = require "util.datamanager"
local dataforms_new = require "util.dataforms".new
local st = require "util.stanza"
local id_gen = require "util.uuid".generate

local pairs, os_time, setmetatable = pairs, os.time, setmetatable

local xmlns_inc = "urn:xmpp:incident:2"
local xmlns_iodef = "urn:ietf:params:xml:ns:iodef-1.0"

local my_host = module:get_host()
local ih_lib = module:require("incidents_handling")
ih_lib.set_my_host(my_host)
incidents = {}

local expire_time = module:get_option_number("incidents_expire_time", 0)

-- Incidents Table Methods

local _inc_mt = {} ; _inc_mt.__index = _inc_mt

function _inc_mt:init()
	self:clean() ; self:save()
end

function _inc_mt:clean()
	if expire_time > 0 then
		for id, incident in pairs(self) do
			if ((os_time() - incident.time) > expire_time) and incident.status ~= "open" then
				incident = nil
			end
		end
	end
end

function _inc_mt:save()
	if not datamanager.store("incidents", my_host, "incidents_store", incidents) then
		module:log("error", "Failed to save the incidents store!")
	end
end

function _inc_mt:add(stanza, report)
	local data = ih_lib.stanza_parser(stanza)
	local new_object = {
		time = os_time(),
		status = (not report and "open") or nil,
		data = data
	}

	self[data.id.text] = new_object
	self:clean() ; self:save()
end

function _inc_mt:new_object(fields, formtype)
	local start_time, end_time, report_time = fields.started, fields.ended, fields.reported

	local _desc, _contacts, _related, _impact, _sources, _targets = fields.description, fields.contacts, fields.related, fields.impact, fields.sources, fields.targets
	local fail = false

	local _lang, _dtext = _desc:match("^(%a%a)%s(.*)$")
	if not _lang or not _dtext then return false end
	local desc = { text = _dtext, lang = _lang }

	local contacts = {}
	for contact in _contacts:gmatch("[%w%p]+%s[%w%p]+%s[%w%p]+") do
		local address, atype, role = contact:match("^([%w%p]+)%s([%w%p]+)%s([%w%p]+)$")
		if not address or not atype or not role then fail = true ; break end
		contacts[#contacts + 1] = {
			role = role,
			ext_role = (role ~= "creator" or role ~= "admin" or role ~= "tech" or role ~= "irt" or role ~= "cc" and true) or nil,
			type = atype,
			ext_type = (atype ~= "person" or atype ~= "organization" and true) or nil,
			jid = (atype == "jid" and address) or nil,
			email = (atype == "email" and address) or nil,
			telephone = (atype == "telephone" and address) or nil,
			postaladdr = (atype == "postaladdr" and address) or nil
		}
	end

	local related = {}
	if _related then
		for related in _related:gmatch("[%w%p]+%s[%w%p]+") do
			local fqdn, id = related:match("^([%w%p]+)%s([%w%p]+)$")
			if fqdn and id then related[#related + 1] = { text = id, name = fqdn } end
		end
	end

	local _severity, _completion, _type = _impact:match("^([%w%p]+)%s([%w%p]+)%s([%w%p]+)$")
	local assessment = { lang = "en", severity = _severity, completion = _completion, type = _type }

	local sources = {}
	for source in _sources:gmatch("[%w%p]+%s[%w%p]+%s[%d]+%s[%w%p]+") do
		local address, cat, count, count_type = source:match("^([%w%p]+)%s([%w%p]+)%s(%d+)%s([%w%p]+)$")
		if not address or not cat or not count or not count_type then fail = true ; break end
		local cat, cat_ext = ih_lib.get_type(cat, "category")
		local count_type, count_ext = ih_lib.get_type(count_type, "counter")

		sources[#sources + 1] = {
			address = { cat = cat, ext = cat_ext, text = address },
			counter = { type = count_type, ext_type = count_ext, value = count }
		}
	end

	local targets, _preprocess = {}, {}
	for target in _targets:gmatch("[%w%p]+%s[%w%p]+%s[%w%p]+") do
		local address, cat, noderole, noderole_ext
		local address, cat, noderole = target:match("^([%w%p]+)%s([%w%p]+)%s([%w%p]+)$")
		if not address or not cat or not noderole then fail = true ; break end
		cat, cat_ext = ih_lib.get_type(cat, "category")
		noderole_ext = ih_lib.get_type(cat, "noderole")

		if not _preprocess[noderole] then _preprocess[noderole] = { addresses = {}, ext = noderole_ext } end

		_preprocess[noderole].addresses[#_preprocess[noderole].addresses + 1] = {
			text = address, cat = cat, ext = cat_ext
		}
	end
	for noderole, data in pairs(_preprocess) do
		local nr_cat = (data.ext and "ext-category") or noderole
		local nr_ext = (data.ext and noderole) or nil
		targets[#targets + 1] = { addresses = data.addresses, noderole = { cat = nr_cat, ext = nr_ext } }
	end

	local new_object = {}
	if not fail then
		new_object["time"] = os_time()
		new_object["status"] = (formtype == "request" and "open") or nil
		new_object["type"] = formtype
		new_object["data"] = {
			id = { text = id_gen(), name = my_host },
			start_time = start_time,
			end_time = end_time,
			report_time = report_time,
			desc = desc,
			contacts = contacts,
			related = related,
			assessment = assessment,
			event_data = { sources = sources, targets = targets }
		}

		self[new_object.data.id.text] = new_object
		self:clean() ; self:save()
		return new_object.data.id.text
	else return false end
end

-- // Handler Functions //

local function report_handler(event)
	local origin, stanza = event.origin, event.stanza

	incidents:add(stanza, true)
	return origin.send(st.reply(stanza))
end

local function inquiry_handler(event)
	local origin, stanza = event.origin, event.stanza

	local inc_id = stanza:get_child("inquiry", xmlns_inc):get_child("Incident", xmlns_iodef):get_child("IncidentID"):get_text()
	if incidents[inc_id] then
		module:log("debug", "Server %s queried for incident %s which we know about, sending it", stanza.attr.from, inc_id)
		local report_iq = stanza_construct(incidents[inc_id])
		report_iq.attr.from = stanza.attr.to
		report_iq.attr.to = stanza.attr.from
		report_iq.attr.type = "set"

		origin.send(st.reply(stanza))
		origin.send(report_iq)
		return true
	else
		module:log("error", "Server %s queried for incident %s but we don't know about it", stanza.attr.from, inc_id)
		origin.send(st.error_reply(stanza, "cancel", "item-not-found")) ; return true
	end
end

local function request_handler(event)
	local origin, stanza = event.origin, event.stanza

	local req_id = stanza:get_child("request", xmlns_inc):get_child("Incident", xmlns_iodef):get_child("IncidentID"):get_text()
	if not incidents[req_id] then
		origin.send(st.error_reply(stanza, "cancel", "item-not-found")) ; return true
	else
		origin.send(st.reply(stanza)) ; return true
	end
end

local function response_handler(event)
	local origin, stanza = event.origin, event.stanza

	local res_id = stanza:get_child("response", xmlns_inc):get_child("Incident", xmlns_iodef):get_child("IncidentID"):get_text()
	if incidents[res_id] then
		incidents[res_id] = nil
		incidents:add(stanza, true)
		origin.send(st.reply(stanza)) ; return true
	else
		origin.send(st.error_reply(stanza, "cancel", "item-not-found")) ; return true
	end
end

local function results_handler(event) return true end -- TODO results handling

-- // Adhoc Commands //

local function list_incidents_command_handler(self, data, state)
	local list_incidents_layout = ih_lib.render_list(incidents)

	if state then
		if state.step == 1 then
			if data.action == "cancel" then
				return { status = "canceled" }
			elseif data.action == "prev" then
				return { status = "executing", actions = { "next", default = "next" }, form = list_incidents_layout }, {}
			end

			local single_incident_layout = state.form_layout
			local fields = single_incident_layout:data(data.form)

			if fields.response then
				incidents[state.id].status = "closed"

				local iq_send = ih_lib.stanza_construct(incidents[state.id])
				module:send(iq_send)
				return { status = "completed", info = "Response sent." }
			else
				return { status = "completed" }
			end
		else
			if data.action == "cancel" then return { status = "canceled" } end
			local fields = list_incidents_layout:data(data.form)

			if fields.ids then
				local single_incident_layout = ih_lib.render_single(incidents[fields.ids])
				return { status = "executing", actions = { "prev", "complete", default = "complete" }, form = single_incident_layout }, { step = 1, form_layout = single_incident_layout, id = fields.ids }
			else
				return { status = "completed", error = { message = "You need to select the report ID to continue." } }
			end
		end
	else
		return { status = "executing", actions = { "next", default = "next" }, form = list_incidents_layout }, {}
	end
end

local function send_inquiry_command_handler(self, data, state)
	local send_inquiry_layout = dataforms_new{
		title = "Send an inquiry about an incident report to a host";
		instructions = "Please specify both the server host and the incident ID.";

		{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/commands" };
		{ name = "server", type = "text-single", label = "Server to inquiry" };
		{ name = "hostname", type = "text-single", label = "Involved incident host" };
		{ name = "id", type = "text-single", label = "Incident ID" };
	}

	if state then
		if data.action == "cancel" then return { status = "canceled" } end
		local fields = send_inquiry_layout:data(data.form)

		if not fields.hostname or not fields.id or not fields.server then
			return { status = "completed", error = { message = "You must supply the server to quest, the involved incident host and the incident ID." } }
		else
			local iq_send = st.iq({ from = my_host, to = fields.server, type = "get" })
						:tag("inquiry", { xmlns = xmlns_inc })
							:tag("Incident", { xmlns = xmlns_iodef, purpose = "traceback" })
								:tag("IncidentID", { name = data.hostname }):text(fields.id):up():up():up()

			module:log("debug", "Sending incident inquiry to %s", fields.server)
			module:send(iq_send)
			return { status = "completed", info = "Inquiry sent, if an answer can be obtained from the remote server it'll be listed between incidents." }
		end
	else
		return { status = "executing", form = send_inquiry_layout }, "executing"
	end
end

local function rr_command_handler(self, data, state, formtype)
	local send_layout = ih_lib.get_incident_layout(formtype)
	local err_no_fields = { status = "completed", error = { message = "You need to fill all fields, except the eventual related incident." } }
	local err_proc = { status = "completed", error = { message = "There was an error processing your request, check out the syntax" } }

	if state then
		if data.action == "cancel" then return { status = "canceled" } end
		local fields = send_layout:data(data.form)

		if fields.started and fields.ended and fields.reported and fields.description and fields.contacts and
		   fields.impact and fields.sources and fields.targets and fields.entity then
			if formtype == "request" and not fields.expectation then return err_no_fields end
			local id = incidents:new_object(fields, formtype)
			if not id then return err_proc end

			local stanza = ih_lib.stanza_construct(id)
			stanza.attr.from = my_host
			stanza.attr.to = fields.entity
			module:log("debug","Sending incident %s stanza to: %s", formtype, stanza.attr.to)
			module:send(stanza)

			return { status = "completed", info = string.format("Incident %s sent to %s.", formtype, fields.entity) }
		else
			return err_no_fields
		end
	else
		return { status = "executing", form = send_layout }, "executing"
	end
end

local function send_report_command_handler(self, data, state)
	return rr_command_handler(self, data, state, "report")
end

local function send_request_command_handler(self, data, state)
	return rr_command_handler(self, data, state, "request")
end

local adhoc_new = module:require "adhoc".new
local list_incidents_descriptor = adhoc_new("List Incidents", xmlns_inc.."#list", list_incidents_command_handler, "admin")
local send_inquiry_descriptor = adhoc_new("Send Incident Inquiry", xmlns_inc.."#send_inquiry", send_inquiry_command_handler, "admin")
local send_report_descriptor = adhoc_new("Send Incident Report", xmlns_inc.."#send_report", send_report_command_handler, "admin")
local send_request_descriptor = adhoc_new("Send Incident Request", xmlns_inc.."#send_request", send_request_command_handler, "admin")
module:provides("adhoc", list_incidents_descriptor)
module:provides("adhoc", send_inquiry_descriptor)
module:provides("adhoc", send_report_descriptor)
module:provides("adhoc", send_request_descriptor)

-- // Hooks //

module:hook("iq-set/host/urn:xmpp:incident:2:report", report_handler)
module:hook("iq-get/host/urn:xmpp:incident:2:inquiry", inquiry_handler)
module:hook("iq-get/host/urn:xmpp:incident:2:request", request_handler)
module:hook("iq-set/host/urn:xmpp:incident:2:response", response_handler)
module:hook("iq-result/host/urn:xmpp:incident:2", results_handler)

-- // Module Methods //

module.load = function()
	if datamanager.load("incidents", my_host, "incidents_store") then incidents = datamanager.load("incidents", my_host, "incidents_store") end
	setmetatable(incidents, _inc_mt) ; incidents:init()
end

module.save = function()
	return { incidents = incidents }
end

module.restore = function(data)
	incidents = data.incidents or {}
	setmetatable(incidents, _inc_mt) ; incidents:init()
end