--
-- (C) 2014-21 - ntop.org
--
local dirs = ntop.getDirs()
package.path = dirs.installdir .. "/scripts/lua/modules/pools/?.lua;" .. package.path
package.path = dirs.installdir .. "/scripts/lua/modules/alert_store/?.lua;" .. package.path
-- This file contains the description of all functions
-- used to trigger host alerts
local verbose = ntop.getCache("ntopng.prefs.alerts.debug") == "1"
local callback_utils = require "callback_utils"
local template = require "template_utils"
local json = require("dkjson")
local host_pools = require "host_pools"
local recovery_utils = require "recovery_utils"
local alert_severities = require "alert_severities"
local alert_entities = require "alert_entities"
local alert_consts = require "alert_consts"
local format_utils = require "format_utils"
local telemetry_utils = require "telemetry_utils"
local tracker = require "tracker"
local alerts_api = require "alerts_api"
local icmp_utils = require "icmp_utils"
local user_scripts = require "user_scripts"
local shaper_utils = nil
if(ntop.isnEdge()) then
package.path = dirs.installdir .. "/pro/scripts/lua/modules/?.lua;" .. package.path
shaper_utils = require("shaper_utils")
end
-- ##############################################
local alert_utils = {}
-- ##############################################
if ntop.isEnterpriseM() then
local dirs = ntop.getDirs()
package.path = dirs.installdir .. "/pro/scripts/lua/enterprise/modules/?.lua;" .. package.path
-- add enterprise utils to this module
alert_utils = require "enterprise_alert_utils"
end
-- ##############################################
local function alertTypeDescription(alert_key, entity_id)
local alert_id = alert_consts.getAlertType(alert_key, entity_id)
if(alert_id) then
if alert_consts.alert_types[alert_id].format then
-- New API
return alert_consts.alert_types[alert_id].format
else
-- TODO: Possible removed once migration is done
return(alert_consts.alert_types[alert_id].i18n_description)
end
end
return nil
end
-- ##############################################
local function get_make_room_keys(ifId)
return {flows="ntopng.cache.alerts.ifid_"..ifId..".make_room_flow_alerts",
entities="ntopng.cache.alerts.ifid_"..ifId..".make_room_closed_alerts"}
end
-- #################################
-- This function maps the SQLite table names to the conventional table
-- names used in this script
local function luaTableName(sqlite_table_name)
--~ ALERTS_MANAGER_FLOWS_TABLE_NAME "flows_alerts"
if(sqlite_table_name == "flows_alerts") then
return("historical-flows")
else
return("historical")
end
end
-- #################################
local function performAlertsQuery(statement, what, opts, force_query, group_by)
local wargs = {"1=1"}
local oargs = {}
if(group_by ~= nil) then
group_by = " GROUP BY " .. group_by
else
group_by = ""
end
if tonumber(opts.row_id) ~= nil then
wargs[#wargs+1] = 'AND rowid = '..(opts.row_id)
end
if (not isEmptyString(opts.entity)) and (not isEmptyString(opts.entity_val)) then
if(what == "historical-flows") then
if(tonumber(opts.entity) ~= alert_consts.alertEntity("host")) then
return({})
else
-- need to handle differently for flows table
local info = hostkey2hostinfo(opts.entity_val)
wargs[#wargs+1] = 'AND (cli_addr="'..(info.host)..'" OR srv_addr="'..(info.host)..'")'
wargs[#wargs+1] = 'AND vlan_id='..(info.vlan)
end
else
wargs[#wargs+1] = 'AND alert_entity = "'..(opts.entity)..'"'
wargs[#wargs+1] = 'AND alert_entity_val = "'..(opts.entity_val)..'"'
end
elseif (what ~= "historical-flows") then
if (not isEmptyString(opts.entity)) then
wargs[#wargs+1] = 'AND alert_entity = "'..(opts.entity)..'"'
end
end
if not isEmptyString(opts.origin) then
local info = hostkey2hostinfo(opts.origin)
wargs[#wargs+1] = 'AND cli_addr="'..(info.host)..'"'
wargs[#wargs+1] = 'AND vlan_id='..(info.vlan)
end
if not isEmptyString(opts.target) then
local info = hostkey2hostinfo(opts.target)
wargs[#wargs+1] = 'AND srv_addr="'..(info.host)..'"'
wargs[#wargs+1] = 'AND vlan_id='..(info.vlan)
end
if tonumber(opts.epoch_begin) ~= nil then
wargs[#wargs+1] = 'AND alert_tstamp >= '..(opts.epoch_begin)
end
if tonumber(opts.epoch_end) ~= nil then
wargs[#wargs+1] = 'AND alert_tstamp <= '..(opts.epoch_end)
end
if not isEmptyString(opts.flowhosts_type) then
if opts.flowhosts_type ~= "all_hosts" then
local cli_local, srv_local = 0, 0
if opts.flowhosts_type == "local_only" then cli_local, srv_local = 1, 1
elseif opts.flowhosts_type == "remote_only" then cli_local, srv_local = 0, 0
elseif opts.flowhosts_type == "local_origin_remote_target" then cli_local, srv_local = 1, 0
elseif opts.flowhosts_type == "remote_origin_local_target" then cli_local, srv_local = 0, 1
end
if what == "historical-flows" then
wargs[#wargs+1] = "AND cli_localhost = "..cli_local
wargs[#wargs+1] = "AND srv_localhost = "..srv_local
end
-- TODO cannot apply it to other tables right now
end
end
if tonumber(opts.alert_id) ~= nil then
wargs[#wargs+1] = "AND alert_id = "..(opts.alert_id)
end
if what == "historical-flows" then
if tonumber(opts.alert_l7_proto) ~= nil then
wargs[#wargs+1] = "AND l7_proto = "..(opts.alert_l7_proto)
end
end
if((not isEmptyString(opts.sortColumn)) and (not isEmptyString(opts.sortOrder))) then
local order_by
if opts.sortColumn == "column_date" then
order_by = "tstamp"
elseif opts.sortColumn == "column_key" then
order_by = "rowid"
elseif opts.sortColumn == "column_type" then
order_by = "alert_id"
elseif opts.sortColumn == "column_count" and what ~= "engaged" then
order_by = "alert_counter"
elseif opts.sortColumn == "column_score" and what ~= "engaged" then
order_by = "score"
elseif((opts.sortColumn == "column_duration") and (what == "historical")) then
order_by = "(alert_tstamp_end - alert_tstamp)"
else
-- default
order_by = "alert_tstamp"
end
oargs[#oargs+1] = "ORDER BY "..order_by
oargs[#oargs+1] = string.upper(opts.sortOrder)
end
-- pagination
if((tonumber(opts.perPage) ~= nil) and (tonumber(opts.currentPage) ~= nil)) then
local to_skip = (tonumber(opts.currentPage)-1) * tonumber(opts.perPage)
oargs[#oargs+1] = "LIMIT"
oargs[#oargs+1] = to_skip..","..(opts.perPage)
end
local query = table.concat(wargs, " ")
group_by = table.concat(oargs, " ") .. group_by
local res
-- Uncomment to debug the queries
-- tprint(statement.." (from "..what..") WHERE "..query .. " ".. group_by)
if((what == "engaged") or (what == "historical")) then
res = interface.queryAlertsRaw(statement, query, group_by, force_query)
elseif what == "historical-flows" then
res = interface.queryFlowAlertsRaw(statement, query, group_by, force_query)
else
error("Invalid alert subject: "..what)
end
return res
end
-- #################################
local function getNumEngagedAlerts(options)
local entity_type_filter = tonumber(options.entity)
local entity_value_filter = options.entity_val
local res = interface.getEngagedAlertsCount(entity_type_filter, entity_value_filter)
if(res ~= nil) then
return(res.num_alerts)
end
return(0)
end
-- #################################
-- Remove pagination options from the options
local function getUnpagedAlertOptions(options)
local res = {}
local paged_option = { currentPage=1, perPage=1, sortColumn=1, sortOrder=1 }
for k,v in pairs(options) do
if not paged_option[k] then
res[k] = v
end
end
return res
end
-- #################################
function alert_utils.getNumAlerts(what, options)
local num = 0
if(what == "engaged") then
num = getNumEngagedAlerts(options)
else
local opts = getUnpagedAlertOptions(options or {})
local res = performAlertsQuery("SELECT COUNT(*) AS count", what, opts)
if((res ~= nil) and (#res == 1) and (res[1].count ~= nil)) then num = tonumber(res[1].count) end
end
return num
end
-- #################################
-- Faster than of getNumAlerts
function alert_utils.hasAlerts(what, options)
if(what == "engaged") then
return(getNumEngagedAlerts(options) > 0)
end
local opts = getUnpagedAlertOptions(options or {})
-- limit 1
opts.perPage = 1
opts.currentPage = 1
local res = performAlertsQuery("SELECT rowid", what, opts)
if((res ~= nil) and (#res == 1)) then
return(true)
else
return(false)
end
end
-- #################################
local function engagedAlertsQuery(params)
local type_filter = tonumber(params.alert_id)
local entity_type_filter = tonumber(params.entity)
local entity_value_filter = params.entity_val
local perPage = tonumber(params.perPage or 10)
local sortColumn = params.sortColumn or "column_"
local sortOrder = params.sortOrder or "desc"
local sOrder = ternary(sortOrder == "desc", rev_insensitive, asc_insensitive)
local currentPage = tonumber(params.currentPage or 1)
local totalRows = 0
-- tprint(string.format("type=%s sev=%s entity=%s val=%s", type_filter, severity_filter, entity_type_filter, entity_value_filter))
local alerts = interface.getEngagedAlerts(entity_type_filter, entity_value_filter, type_filter)
local sort_2_col = {}
-- Sort
for idx, alert in pairs(alerts) do
if sortColumn == "column_type" then
sort_2_col[idx] = alert.alert_id
elseif sortColumn == "column_duration" then
sort_2_col[idx] = os.time() - alert.tstamp
else -- column_date
sort_2_col[idx] = alert.tstamp
end
totalRows = totalRows + 1
end
-- Pagination
local to_skip = (currentPage-1) * perPage
local totalRows = #alerts
local res = {}
local i = 0
for idx in pairsByValues(sort_2_col, sOrder) do
if i >= to_skip + perPage then
break
end
if (i >= to_skip) then
res[#res + 1] = alerts[idx]
end
i = i + 1
end
return res, totalRows
end
-- #################################
--@brief Deletes all stored alerts matching an host and an IP
-- @return nil
function alert_utils.deleteFlowAlertsMatching(host_ip, alert_id)
local flow_alert_store = require("flow_alert_store").new()
if not isEmptyString(host_ip) then
flow_alert_store:add_ip_filter(hostkey2hostinfo(host_ip)["host"])
end
flow_alert_store:add_alert_id_filter(alert_id)
-- Perform the actual deletion
flow_alert_store:delete()
end
-- #################################
--@brief Deletes all stored alerts matching an host and an IP
-- @return nil
function alert_utils.deleteHostAlertsMatching(host_ip, alert_id)
local host_alert_store = require("host_alert_store").new()
if not isEmptyString(host_ip) then
host_alert_store:add_ip_filter(hostkey2hostinfo(host_ip)["host"])
end
host_alert_store:add_alert_id_filter(alert_id)
-- Perform the actual deletion
host_alert_store:delete()
end
-- #################################
-- this function returns an object with parameters specific for one tab
function alert_utils.getTabParameters(_get, what)
local opts = {}
for k,v in pairs(_get) do opts[k] = v end
-- these options are contextual to the current tab (status)
if _get.status ~= what then
opts.alert_id = nil
end
if not isEmptyString(what) then opts.status = what end
opts.ifid = interface.getId()
return opts
end
-- #################################
-- Return more information for the flow alert description
local function getAlertTypeInfo(record, alert_info)
local res = ""
local l7proto_name = interface.getnDPIProtoName(tonumber(record["l7_proto"]) or 0)
if l7proto_name == "ICMP" then -- is ICMPv4
-- TODO: old format - remove when the all the flow alers will be generated in lua
local type_code = {type = alert_info["icmp.icmp_type"], code = alert_info["icmp.icmp_code"]}
if table.empty(type_code) and alert_info["icmp"] then
-- This is the new format created when setting the alert from lua
type_code = {type = alert_info["icmp"]["type"], code = alert_info["icmp"]["code"]}
end
if alert_info["icmp.unreach.src_ip"] then -- TODO: old format to be removed
res = string.format("[%s]", i18n("icmp_page.icmp_port_unreachable_extra", {unreach_host=alert_info["icmp.unreach.dst_ip"], unreach_port=alert_info["icmp.unreach.dst_port"], unreach_protocol = l4_proto_to_string(alert_info["icmp.unreach.protocol"])}))
elseif alert_info["icmp"] and alert_info["icmp"]["unreach"] then -- New format
res = string.format("[%s]", i18n("icmp_page.icmp_port_unreachable_extra", {unreach_host=alert_info["icmp"]["unreach"]["dst_ip"], unreach_port=alert_info["icmp"]["unreach"]["dst_port"], unreach_protocol = l4_proto_to_string(alert_info["icmp"]["unreach"]["protocol"])}))
else
res = string.format("[%s]", icmp_utils.get_icmp_label(4 --[[ ipv4 --]], type_code["type"], type_code["code"]))
end
end
return string.format(" %s", res)
end
-- #################################
-- This function formats flows in alerts
local function formatRawFlow(ifid, alert, alert_json)
require "flow_utils"
local time_bounds
local add_links = (not skip_add_links)
if interfaceHasNindexSupport() and not skip_add_links then
-- only add links if nindex is present
add_links = true
time_bounds = {getAlertTimeBounds(alert)}
end
-- TODO: adapter just to be compatible with old alerts, can be removed at some point
if alert_json["alert_info"] then
alert_json = json.decode(alert_json["alert_info"])
end
-- active flow lookup
if not interface.isView() and alert_json and alert_json["ntopng.key"] and alert_json["hash_entry_id"] and alert["alert_tstamp"] then
-- attempt a lookup on the active flows
local active_flow = interface.findFlowByKeyAndHashId(alert_json["ntopng.key"], alert_json["hash_entry_id"])
if active_flow and active_flow["seen.first"] < tonumber(alert["alert_tstamp"]) then
return string.format(" %s %s",
'',
ntop.getHttpPrefix(), active_flow["ntopng.key"], active_flow["hash_entry_id"],
getFlowLabel(active_flow, true, true))
end
end
-- pretend alert is a flow to reuse getFlowLabel
local flow = {
["cli.ip"] = alert["cli_addr"], ["cli.port"] = tonumber(alert["cli_port"]),
["cli.blacklisted"] = tostring(alert["cli_blacklisted"]) == "1",
["cli.localhost"] = tostring(alert["cli_localhost"]) == "1",
["srv.ip"] = alert["srv_addr"], ["srv.port"] = tonumber(alert["srv_port"]),
["srv.blacklisted"] = tostring(alert["srv_blacklisted"]) == "1",
["srv.localhost"] = tostring(alert["srv_localhost"]) == "1",
["vlan"] = alert["vlan_id"]}
flow = "[ "..(getFlowLabel(flow, false, add_links, time_bounds, {page = "alerts"}) or "").."] "
local l4_proto_label = l4_proto_to_string(alert["proto"] or 0) or ""
if not isEmptyString(l4_proto_label) then
flow = flow.."[" .. l4_proto_label .. "] "
end
if alert_json ~= nil then
-- render the json
local msg = ""
if not isEmptyString(flow) then
msg = msg..flow.." "
end
if not isEmptyString(alert_json["info"]) then
local lb = ""
local info
if string.len(alert_json["info"]) > 24 then
info = "".. shortenString(alert_json["info"], 24)
else
info = alert_json["info"]
end
msg = msg.."[" .. info ..lb.."] "
end
flow = msg
end
if alert_json then
flow = flow..getAlertTypeInfo(alert, alert_json)
end
return flow
end
-- #################################
local function getMenuEntries(status, selection_name, get_params)
local actual_entries = {}
-- table.clone needed to modify some parameters while keeping the original unchanged
local params = table.clone(get_params)
-- Remove previous filters
params.alert_type = nil
params.l7_proto = nil
local select_clause = {} -- Contains the selection which is ,
local group_by_clause = {} -- The clause used to group alerts. It is , for all non-flow alerts
if status == "historical-flows" then
-- Flows don't have the alert_entity as a table column so we just put the entity id as a placeholder
select_clause[#select_clause + 1] = string.format("%u entity", alert_entities.flow.entity_id)
else
-- TODO: entities will be removed when every entity will have its own database table
select_clause[#select_clause + 1] = "alert_entity entity"
group_by_clause[#group_by_clause + 1] = "alert_entity"
end
if selection_name == "type" then
select_clause[#select_clause + 1] = "alert_id id"
group_by_clause[#group_by_clause + 1] = "alert_id"
elseif selection_name == "l7_proto" then
select_clause[#select_clause + 1] = "l7_proto id"
group_by_clause[#group_by_clause + 1] = "l7_proto"
end
select_clause[#select_clause + 1] = "count(*) count"
local select_str = "SELECT "..table.concat(select_clause, ", ")
local group_by_str = table.concat(group_by_clause, ", ")
actual_entries = performAlertsQuery(select_str, status, params, nil, group_by_str --[[ group by ]])
-- tprint({select_str, group_by_str, status, params})
return actual_entries
end
-- #################################
local function dropdownUrlParams(get_params)
local buttons = ""
for param, val in pairs(get_params) do
-- NOTE: exclude the ifid parameter to avoid interface selection issues with system interface alerts
if((param ~= "alert_type") and (param ~= "status") and (param ~= "ifid")) then
buttons = buttons.."&"..param.."="..val
end
end
return(buttons)
end
-- #################################
local function drawDropdown(status, selection_name, active_entry, button_label, get_params, actual_entries)
local id_to_label
if selection_name == "type" then
id_to_label = alert_consts.alertTypeLabel
elseif selection_name == "l7_proto" then
id_to_label = interface.getnDPIProtoName
end
actual_entries = actual_entries or getMenuEntries(status, selection_name, get_params)
local buttons = '