--[[ Copyright (c) 2016, Vsevolod Stakhov Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ]]-- local rspamd_logger = require 'rspamd_logger' local rspamd_http = require "rspamd_http" local rspamd_lua_utils = require "lua_util" local upstream_list = require "rspamd_upstream_list" local N = "clickhouse" if confighelp then return end local E = {} local rows = {} local attachment_rows = {} local urls_rows = {} local emails_rows = {} local specific_rows = {} local asn_rows = {} local symbols_rows = {} local nrows = 0 local connect_prefix = 'http://' local settings = { limit = 1000, timeout = 5.0, bayes_spam_symbols = {'BAYES_SPAM'}, bayes_ham_symbols = {'BAYES_HAM'}, fann_symbols = {'FANN_SCORE'}, fuzzy_symbols = {'FUZZY_DENIED'}, whitelist_symbols = {'WHITELIST_DKIM', 'WHITELIST_SPF_DKIM', 'WHITELIST_DMARC'}, dkim_allow_symbols = {'R_DKIM_ALLOW'}, dkim_reject_symbols = {'R_DKIM_REJECT'}, dmarc_allow_symbols = {'DMARC_POLICY_ALLOW'}, dmarc_reject_symbols = {'DMARC_POLICY_REJECT', 'DMARC_POLICY_QUARANTINE'}, table = 'rspamd', attachments_table = 'rspamd_attachments', urls_table = 'rspamd_urls', emails_table = 'rspamd_emails', symbols_table = 'rspamd_symbols', asn_table = 'rspamd_asn', ipmask = 19, ipmask6 = 48, full_urls = false, from_tables = nil, enable_symbols = false, use_https = false, use_gzip = true, allow_local = false, } local clickhouse_schema = { rspamd = [[ CREATE TABLE IF NOT EXISTS ${table} ( Date Date, TS DateTime, From String, MimeFrom String, IP String, Score Float64, NRcpt UInt8, Size UInt32, IsWhitelist Enum8('blacklist' = 0, 'whitelist' = 1, 'unknown' = 2) DEFAULT CAST('unknown' AS Enum8('blacklist' = 0, 'whitelist' = 1, 'unknown' = 2)), IsBayes Enum8('ham' = 0, 'spam' = 1, 'unknown' = 2) DEFAULT CAST('unknown' AS Enum8('ham' = 0, 'spam' = 1, 'unknown' = 2)), IsFuzzy Enum8('whitelist' = 0, 'deny' = 1, 'unknown' = 2) DEFAULT CAST('unknown' AS Enum8('whitelist' = 0, 'deny' = 1, 'unknown' = 2)), IsFann Enum8('ham' = 0, 'spam' = 1, 'unknown' = 2) DEFAULT CAST('unknown' AS Enum8('ham' = 0, 'spam' = 1, 'unknown' = 2)), IsDkim Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2) DEFAULT CAST('unknown' AS Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2)), IsDmarc Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2) DEFAULT CAST('unknown' AS Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2)), NUrls Int32, Action Enum8('reject' = 0, 'rewrite subject' = 1, 'add header' = 2, 'greylist' = 3, 'no action' = 4, 'soft reject' = 5) DEFAULT CAST('no action' AS Enum8('reject' = 0, 'rewrite subject' = 1, 'add header' = 2, 'greylist' = 3, 'no action' = 4, 'soft reject' = 5)), FromUser String, MimeUser String, RcptUser String, RcptDomain String, ListId String, Digest FixedString(32) ) ENGINE = MergeTree(Date, (TS, From), 8192) ]], attachments = [[ CREATE TABLE IF NOT EXISTS ${attachments_table} ( Date Date, Digest FixedString(32), `Attachments.FileName` Array(String), `Attachments.ContentType` Array(String), `Attachments.Length` Array(UInt32), `Attachments.Digest` Array(FixedString(16)) ) ENGINE = MergeTree(Date, Digest, 8192) ]], urls = [[ CREATE TABLE IF NOT EXISTS ${urls_table} ( Date Date, Digest FixedString(32), `Urls.Tld` Array(String), `Urls.Url` Array(String) ) ENGINE = MergeTree(Date, Digest, 8192) ]], emails = [[ CREATE TABLE IF NOT EXISTS ${emails_table} ( Date Date, Digest FixedString(32), Emails Array(String) ) ENGINE = MergeTree(Date, Digest, 8192) ]], asn = [[ CREATE TABLE IF NOT EXISTS ${asn_table} ( Date Date, Digest FixedString(32), ASN String, Country FixedString(2), IPNet String ) ENGINE = MergeTree(Date, Digest, 8192) ]], symbols = [[ CREATE TABLE IF NOT EXISTS ${symbols_table} ( Date Date, Digest FixedString(32), `Symbols.Names` Array(String), `Symbols.Scores` Array(Float64), `Symbols.Options` Array(String) ) ENGINE = MergeTree(Date, Digest, 8192) ]] } local function clickhouse_main_row(tname) local fields = { 'Date', 'TS', 'From', 'MimeFrom', 'IP', 'Score', 'NRcpt', 'Size', 'IsWhitelist', 'IsBayes', 'IsFuzzy', 'IsFann', 'IsDkim', 'IsDmarc', 'NUrls', 'Action', 'FromUser', 'MimeUser', 'RcptUser', 'RcptDomain', 'ListId', 'Digest' } local elt = string.format('INSERT INTO %s (%s) VALUES ', tname, table.concat(fields, ',')) return elt end local function clickhouse_attachments_row(tname) local attachement_fields = { 'Date', 'Digest', 'Attachments.FileName', 'Attachments.ContentType', 'Attachments.Length', 'Attachments.Digest', } local elt = string.format('INSERT INTO %s (%s) VALUES ', tname, table.concat(attachement_fields, ',')) return elt end local function clickhouse_urls_row(tname) local urls_fields = { 'Date', 'Digest', 'Urls.Tld', 'Urls.Url', } local elt = string.format('INSERT INTO %s (%s) VALUES ', tname, table.concat(urls_fields, ',')) return elt end local function clickhouse_emails_row(tname) local emails_fields = { 'Date', 'Digest', 'Emails', } local elt = string.format('INSERT INTO %s (%s) VALUES ', tname, table.concat(emails_fields, ',')) return elt end local function clickhouse_symbols_row(tname) local symbols_fields = { 'Date', 'Digest', 'Symbols.Names', 'Symbols.Scores', 'Symbols.Options', } local elt = string.format('INSERT INTO %s (%s) VALUES ', tname, table.concat(symbols_fields, ',')) return elt end local function clickhouse_asn_row(tname) local asn_fields = { 'Date', 'Digest', 'ASN', 'Country', 'IPNet', } local elt = string.format('INSERT INTO %s (%s) VALUES ', tname, table.concat(asn_fields, ',')) return elt end local function clickhouse_first_row() table.insert(rows, clickhouse_main_row(settings['table'])) if settings['attachments_table'] then table.insert(attachment_rows, clickhouse_attachments_row(settings['attachments_table'])) end if settings['urls_table'] then table.insert(urls_rows, clickhouse_urls_row(settings['urls_table'])) end if settings['emails_table'] then table.insert(emails_rows, clickhouse_emails_row(settings['emails_table'])) end if settings['asn_table'] then table.insert(asn_rows, clickhouse_asn_row(settings['asn_table'])) end if settings.enable_symbols and settings['symbols_table'] then table.insert(symbols_rows, clickhouse_symbols_row(settings['symbols_table'])) end end local function clickhouse_check_symbol(task, symbols, need_score) for _,s in ipairs(symbols) do if task:has_symbol(s) then if need_score then local sym = task:get_symbol(s)[1] return sym['score'] else return true end end end return false end local function clickhouse_send_data(task) local upstream = settings.upstream:get_upstream_round_robin() local ip_addr = upstream:get_addr():to_string(true) local function gen_http_cb(what, how_many) return function (err_message, code, data, _) if code ~= 200 or err_message then if not err_message then err_message = data end rspamd_logger.errx(task, "cannot send %s data to clickhouse server %s: %s", what, ip_addr, err_message) upstream:fail() else rspamd_logger.infox(task, "sent %s rows of %s to clickhouse server %s", how_many - 1, what, ip_addr) upstream:ok() end end end local body = table.concat(rows, ' ') if not rspamd_http.request({ task = task, url = connect_prefix .. ip_addr, body = body, callback = gen_http_cb('generic data', #rows), gzip = settings.use_gzip, mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(task, "cannot send data to clickhouse server %s: cannot make request", settings['server']) end if #attachment_rows > 1 then body = table.concat(attachment_rows, ' ') if not rspamd_http.request({ task = task, url = connect_prefix .. ip_addr, body = body, callback = gen_http_cb('attachments data', #attachment_rows), mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(task, "cannot send attachments to clickhouse server %s: cannot make request", settings['server']) end end if #urls_rows > 1 then body = table.concat(urls_rows, ' ') if not rspamd_http.request({ task = task, url = connect_prefix .. ip_addr, body = body, callback = gen_http_cb('urls data', #urls_rows), mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(task, "cannot send urls to clickhouse server %s: cannot make request", settings['server']) end end if #emails_rows > 1 then body = table.concat(emails_rows, ' ') if not rspamd_http.request({ task = task, url = connect_prefix .. ip_addr, body = body, callback = gen_http_cb('emails data', #emails_rows), mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(task, "cannot send emails to clickhouse server %s: cannot make request", settings['server']) end end if #asn_rows > 1 then body = table.concat(asn_rows, ' ') if not rspamd_http.request({ task = task, url = connect_prefix .. ip_addr, body = body, callback = gen_http_cb('asn data', #asn_rows), mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(task, "cannot send asn info to clickhouse server %s: cannot make request", settings['server']) end end if #symbols_rows > 1 then body = table.concat(symbols_rows, ' ') if not rspamd_http.request({ task = task, url = connect_prefix .. ip_addr, body = body, callback = gen_http_cb('symbols data', #symbols_rows), mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(task, "cannot send symbols info to clickhouse server %s: cannot make request", settings['server']) end end for k,specific in pairs(specific_rows) do if #specific > 1 then body = table.concat(specific, ' ') if not rspamd_http.request({ task = task, url = connect_prefix .. ip_addr, body = body, callback = gen_http_cb('domain specific data ('..k..')', #specific), mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(task, "cannot send data for domain %s to clickhouse server %s: cannot make request", k, settings['server']) end end end end local function clickhouse_quote(str) if str then return str:gsub('[\'\\]', '\\%1'):lower() else return '' end end local function clickhouse_collect(task) if not settings.allow_local and rspamd_lua_utils.is_rspamc_or_controller(task) then return end local from_domain = '' local from_user = '' if task:has_from('smtp') then local from = task:get_from('smtp')[1] if from then from_domain = from['domain'] from_user = from['user'] end if from_domain == '' then if task:get_helo() then from_domain = task:get_helo() end end else if task:get_helo() then from_domain = task:get_helo() end end local mime_domain = '' local mime_user = '' if task:has_from('mime') then local from = task:get_from('mime')[1] if from then mime_domain = from['domain'] mime_user = from['user'] end end local ip_str = 'undefined' local ip = task:get_from_ip() if ip and ip:is_valid() then local ipnet if ip:get_version() == 4 then ipnet = ip:apply_mask(settings['ipmask']) else ipnet = ip:apply_mask(settings['ipmask6']) end ip_str = ipnet:to_string() end local rcpt_user = '' local rcpt_domain = '' if task:has_recipients('smtp') then local rcpt = task:get_recipients('smtp')[1] rcpt_user = rcpt['user'] rcpt_domain = rcpt['domain'] end local list_id = '' local lh = task:get_header('List-Id') if lh then list_id = lh end local score = task:get_metric_score('default')[1]; local bayes = 'unknown'; local fuzzy = 'unknown'; local fann = 'unknown'; local whitelist = 'unknown'; local dkim = 'unknown'; local dmarc = 'unknown'; local ret ret = clickhouse_check_symbol(task, settings['bayes_spam_symbols'], false) if ret then bayes = 'spam' end ret = clickhouse_check_symbol(task, settings['bayes_ham_symbols'], false) if ret then bayes = 'ham' end ret = clickhouse_check_symbol(task, settings['fann_symbols'], true) if ret then if ret > 0 then fann = 'spam' else fann = 'ham' end end ret = clickhouse_check_symbol(task, settings['whitelist_symbols'], true) if ret then if ret < 0 then whitelist = 'whitelist' else whitelist = 'blacklist' end end ret = clickhouse_check_symbol(task, settings['fuzzy_symbols'], false) if ret then fuzzy = 'deny' end ret = clickhouse_check_symbol(task, settings['dkim_allow_symbols'], false) if ret then dkim = 'allow' end ret = clickhouse_check_symbol(task, settings['dkim_reject_symbols'], false) if ret then dkim = 'reject' end ret = clickhouse_check_symbol(task, settings['dmarc_allow_symbols'], false) if ret then dmarc = 'allow' end ret = clickhouse_check_symbol(task, settings['dmarc_reject_symbols'], false) if ret then dmarc = 'reject' end local nrcpts = 0 if task:has_recipients('smtp') then nrcpts = #task:get_recipients('smtp') end local nurls = 0 if task:has_urls(true) then nurls = #task:get_urls(true) end local timestamp = task:get_date({ format = 'connect', gmt = false }) local elt = string.format("(today(),%d,'%s','%s','%s',%.2f,%d,%d,'%s','%s','%s','%s','%s','%s',%d,'%s','%s','%s','%s','%s','%s','%s')", timestamp, clickhouse_quote(from_domain), clickhouse_quote(mime_domain), ip_str, score, nrcpts, task:get_size(), whitelist, bayes, fuzzy, fann, dkim, dmarc, nurls, task:get_metric_action('default'), clickhouse_quote(from_user), clickhouse_quote(mime_user), clickhouse_quote(rcpt_user), clickhouse_quote(rcpt_domain), clickhouse_quote(list_id), task:get_digest()) table.insert(rows, elt) if settings['from_map'] and dkim == 'allow' then -- Use dkim local das = task:get_symbol(settings['dkim_allow_symbols'][1]) if ((das or E)[1] or E).options then for _,dkim_domain in ipairs(das[1]['options']) do local specific = settings.from_map:get_key(dkim_domain) if specific then if not specific_rows[specific] then local first = clickhouse_main_row(specific) specific_rows[specific] = {first} end table.insert(specific_rows[specific], elt) end end end end -- Attachments step local attachments_fnames = {} local attachments_ctypes = {} local attachments_lengths = {} local attachments_digests = {} for _,part in ipairs(task:get_parts()) do local fname = part:get_filename() if fname then table.insert(attachments_fnames, string.format("'%s'", clickhouse_quote(fname))) local type, subtype = part:get_type() table.insert(attachments_ctypes, string.format("'%s/%s'", clickhouse_quote(type), clickhouse_quote(subtype))) table.insert(attachments_lengths, string.format("%s", tostring(part:get_length()))) table.insert(attachments_digests, string.format("'%s'", string.sub(part:get_digest(), 1, 16))) end end if #attachments_fnames > 0 then elt = string.format("(today(),'%s',[%s],[%s],[%s],[%s])", task:get_digest(), table.concat(attachments_fnames, ','), table.concat(attachments_ctypes, ','), table.concat(attachments_lengths, ','), table.concat(attachments_digests, ',')) table.insert(attachment_rows, elt) end -- Urls step local urls_tlds = {} local urls_urls = {} if task:has_urls(false) then for _,u in ipairs(task:get_urls(false)) do table.insert(urls_tlds, string.format("'%s'", clickhouse_quote(u:get_tld()))) if settings['full_urls'] then table.insert(urls_urls, string.format("'%s'", clickhouse_quote(u:get_text()))) else table.insert(urls_urls, string.format("'%s'", clickhouse_quote(u:get_host()))) end end end if #urls_tlds > 0 then elt = string.format("(today(),'%s',[%s],[%s])", task:get_digest(), table.concat(urls_tlds, ','), table.concat(urls_urls, ',')) table.insert(urls_rows, elt) end -- Emails step local emails = {} if task:has_urls(true) then for _,u in ipairs(task:get_emails()) do table.insert(emails, string.format("'%s'", clickhouse_quote( string.format('%s@%s', u:get_user(), u:get_host()) ))) end end if #emails > 0 then elt = string.format("(today(),'%s',[%s])", task:get_digest(), table.concat(emails, ',')) table.insert(emails_rows, elt) end -- ASN information if settings['asn_table'] then local asn, country, ipnet = '--', '--', '--' local pool = task:get_mempool() ret = pool:get_variable("asn") if ret then asn = ret end ret = pool:get_variable("country") if ret then country = ret:sub(1, 2) end ret = pool:get_variable("ipnet") if ret then ipnet = ret end elt = string.format("(today(),'%s','%s','%s','%s')", task:get_digest(), clickhouse_quote(asn), clickhouse_quote(country), clickhouse_quote(ipnet)) table.insert(asn_rows, elt) end -- Symbols info if settings.enable_symbols and settings['symbols_table'] then local symbols = task:get_symbols_all() local syms_tab = {} local scores_tab = {} local options_tab = {} for _,s in ipairs(symbols) do table.insert(syms_tab, string.format("'%s'", clickhouse_quote(s.name or ''))) table.insert(scores_tab, string.format('%.3f', s.score)) if s.options then table.insert(options_tab, string.format("'%s'", clickhouse_quote(table.concat(s.options, ',')))) else table.insert(options_tab, "''"); end end elt = string.format("(today(),'%s',[%s],[%s],[%s])", task:get_digest(), table.concat(syms_tab, ','), table.concat(scores_tab, ','), table.concat(options_tab, ',')) table.insert(symbols_rows, elt) end nrows = nrows + 1 if nrows > settings['limit'] then clickhouse_send_data(task) nrows = 0 rows = {} attachment_rows = {} urls_rows = {} emails_rows = {} specific_rows = {} asn_rows = {} symbols_rows = {} clickhouse_first_row() end end local opts = rspamd_config:get_all_opt('clickhouse') if opts then for k,v in pairs(opts) do settings[k] = v end if not settings['server'] and not settings['servers'] then rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module') rspamd_lua_utils.disable_module(N, "config") else settings['from_map'] = rspamd_map_add('clickhouse', 'from_tables', 'regexp', 'clickhouse specific domains') if settings.use_https then connect_prefix = 'https://' end settings.upstream = upstream_list.create(rspamd_config, settings['server'] or settings['servers'], 8123) if not settings.upstream then rspamd_logger.errx('cannot parse clickhouse address: %s', settings['server'] or settings['servers']) rspamd_lua_utils.disable_module(N, "config") return end clickhouse_first_row() rspamd_config:register_symbol({ name = 'CLICKHOUSE_COLLECT', type = 'idempotent', callback = clickhouse_collect, priority = 10, flags = 'empty', }) rspamd_config:register_finish_script(function(task) if nrows > 0 then clickhouse_send_data(task) end end) -- Create tables on load rspamd_config:add_on_load(function(cfg, ev_base, worker) if worker:is_primary_controller() then local upstreams = settings.upstream:all_upstreams() for _,up in ipairs(upstreams) do local ip_addr = up:get_addr():to_string(true) local function send_req(elt, sql) local function http_cb(err_message, code, data, _) if code ~= 200 or err_message then if not err_message then err_message = data end rspamd_logger.errx(rspamd_config, "cannot create table %s in clickhouse server %s: %s", elt, ip_addr, err_message) up:fail() else up:ok() end end if not rspamd_http.request({ ev_base = ev_base, config = cfg, url = connect_prefix .. ip_addr, body = sql, callback = http_cb, mime_type = 'text/plain', timeout = settings['timeout'], }) then rspamd_logger.errx(rspamd_config, "cannot create table %s in clickhouse server %s: cannot make request", elt, ip_addr) end end for tab,sql in pairs(clickhouse_schema) do send_req(tab, rspamd_lua_utils.template(sql, settings)) end end end end) end end