You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. --[[
  2. Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. ]]--
  13. if confighelp then
  14. rspamd_config:add_example(nil, 'history_redis',
  15. "Store history of checks for WebUI using Redis",
  16. [[
  17. redis_history {
  18. # History key name
  19. key_prefix = 'rs_history{{HOSTNAME}}{{COMPRESS}}';
  20. # History expire in seconds
  21. expire = 0;
  22. # History rows limit
  23. nrows = 200;
  24. # Use zstd compression when storing data in redis
  25. compress = true;
  26. # Obfuscate subjects for privacy
  27. subject_privacy = false;
  28. # Default hash-algorithm to obfuscate subject
  29. subject_privacy_alg = 'blake2';
  30. # Prefix to show it's obfuscated
  31. subject_privacy_prefix = 'obf';
  32. # Cut the length of the hash if desired
  33. subject_privacy_length = 16;
  34. }
  35. ]])
  36. return
  37. end
  38. local rspamd_logger = require "rspamd_logger"
  39. local rspamd_util = require "rspamd_util"
  40. local lua_util = require "lua_util"
  41. local lua_redis = require "lua_redis"
  42. local fun = require "fun"
  43. local ucl = require "ucl"
  44. local ts = (require "tableshape").types
  45. local E = {}
  46. local N = "history_redis"
  47. local template_env = {
  48. HOSTNAME = rspamd_util.get_hostname(),
  49. }
  50. local redis_params
  51. local settings = {
  52. key_prefix = 'rs_history{{HOSTNAME}}{{COMPRESS}}', -- default key name template
  53. expire = nil, -- default no expire
  54. nrows = 200, -- default rows limit
  55. compress = true, -- use zstd compression when storing data in redis
  56. subject_privacy = false, -- subject privacy is off
  57. subject_privacy_alg = 'blake2', -- default hash-algorithm to obfuscate subject
  58. subject_privacy_prefix = 'obf', -- prefix to show it's obfuscated
  59. subject_privacy_length = 16, -- cut the length of the hash
  60. }
  61. local settings_schema = lua_redis.enrich_schema({
  62. key_prefix = ts.string,
  63. expire = (ts.number + ts.string / lua_util.parse_time_interval):is_optional(),
  64. nrows = ts.number,
  65. compress = ts.boolean,
  66. subject_privacy = ts.boolean:is_optional(),
  67. subject_privacy_alg = ts.string:is_optional(),
  68. subject_privacy_prefix = ts.string:is_optional(),
  69. subject_privacy_length = ts.number:is_optional(),
  70. })
  71. local function process_addr(addr)
  72. if addr then
  73. return addr.addr
  74. end
  75. return 'unknown'
  76. end
  77. local function normalise_results(tbl, task)
  78. local metric = tbl.default
  79. -- Convert stupid metric object
  80. if metric then
  81. tbl.symbols = {}
  82. local symbols, others = fun.partition(function(_, v)
  83. return type(v) == 'table' and v.score
  84. end, metric)
  85. fun.each(function(k, v)
  86. v.name = nil;
  87. tbl.symbols[k] = v;
  88. end, symbols)
  89. fun.each(function(k, v)
  90. tbl[k] = v
  91. end, others)
  92. -- Reset the original metric
  93. tbl.default = nil
  94. end
  95. -- Now, add recipients and senders
  96. tbl.sender_smtp = process_addr((task:get_from('smtp') or E)[1])
  97. tbl.sender_mime = process_addr((task:get_from('mime') or E)[1])
  98. tbl.rcpt_smtp = fun.totable(fun.map(process_addr, task:get_recipients('smtp') or {}))
  99. tbl.rcpt_mime = fun.totable(fun.map(process_addr, task:get_recipients('mime') or {}))
  100. tbl.user = task:get_user() or 'unknown'
  101. tbl.rmilter = nil
  102. tbl.messages = nil
  103. tbl.urls = nil
  104. tbl.action = task:get_metric_action()
  105. local seconds = task:get_timeval()['tv_sec']
  106. tbl.unix_time = seconds
  107. local subject = task:get_header('subject') or 'unknown'
  108. tbl.subject = lua_util.maybe_obfuscate_string(subject, settings, 'subject')
  109. tbl.size = task:get_size()
  110. local ip = task:get_from_ip()
  111. if ip and ip:is_valid() then
  112. tbl.ip = tostring(ip)
  113. else
  114. tbl.ip = 'unknown'
  115. end
  116. tbl.user = task:get_user() or 'unknown'
  117. end
  118. local function history_save(task)
  119. local function redis_llen_cb(err, _)
  120. if err then
  121. rspamd_logger.errx(task, 'got error %s when writing history row: %s',
  122. err)
  123. end
  124. end
  125. -- We skip saving it to the history
  126. if task:has_flag('no_log') then
  127. return
  128. end
  129. local data = task:get_protocol_reply { 'metrics', 'basic' }
  130. local prefix = lua_util.jinja_template(settings.key_prefix, template_env, false, true)
  131. if data then
  132. normalise_results(data, task)
  133. else
  134. rspamd_logger.errx('cannot get protocol reply, skip saving in history')
  135. return
  136. end
  137. local json = ucl.to_format(data, 'json-compact')
  138. if settings.compress then
  139. json = rspamd_util.zstd_compress(json)
  140. end
  141. local ret, conn, _ = lua_redis.rspamd_redis_make_request(task,
  142. redis_params, -- connect params
  143. nil, -- hash key
  144. true, -- is write
  145. redis_llen_cb, --callback
  146. 'LPUSH', -- command
  147. { prefix, json } -- arguments
  148. )
  149. if ret then
  150. conn:add_cmd('LTRIM', { prefix, '0', string.format('%d', settings.nrows - 1) })
  151. if settings.expire and settings.expire > 0 then
  152. conn:add_cmd('EXPIRE', { prefix, string.format('%d', settings.expire) })
  153. end
  154. end
  155. end
  156. local function handle_history_request(task, conn, from, to, reset)
  157. local prefix = lua_util.jinja_template(settings.key_prefix, template_env, false, true)
  158. if reset then
  159. local function redis_ltrim_cb(err, _)
  160. if err then
  161. rspamd_logger.errx(task, 'got error %s when resetting history: %s',
  162. err)
  163. conn:send_error(504, '{"error": "' .. err .. '"}')
  164. else
  165. conn:send_string('{"success":true}')
  166. end
  167. end
  168. lua_redis.rspamd_redis_make_request(task,
  169. redis_params, -- connect params
  170. nil, -- hash key
  171. true, -- is write
  172. redis_ltrim_cb, --callback
  173. 'LTRIM', -- command
  174. { prefix, '0', '0' } -- arguments
  175. )
  176. else
  177. local function redis_lrange_cb(err, data)
  178. if data then
  179. local reply = {
  180. version = 2,
  181. }
  182. if settings.compress then
  183. local t1 = rspamd_util:get_ticks()
  184. data = fun.totable(fun.filter(function(e)
  185. return e ~= nil
  186. end,
  187. fun.map(function(e)
  188. local _, dec = rspamd_util.zstd_decompress(e)
  189. if dec then
  190. return dec
  191. end
  192. return nil
  193. end, data)))
  194. lua_util.debugm(N, task, 'decompress took %s ms',
  195. (rspamd_util:get_ticks() - t1) * 1000.0)
  196. collectgarbage()
  197. end
  198. -- Parse elements using ucl
  199. local t1 = rspamd_util:get_ticks()
  200. data = fun.totable(
  201. fun.map(function(_, obj)
  202. return obj
  203. end,
  204. fun.filter(function(res, obj)
  205. if res then
  206. return true
  207. end
  208. return false
  209. end,
  210. fun.map(function(elt)
  211. local parser = ucl.parser()
  212. local res, _ = parser:parse_text(elt)
  213. if res then
  214. return true, parser:get_object()
  215. else
  216. return false, nil
  217. end
  218. end, data))))
  219. lua_util.debugm(N, task, 'parse took %s ms',
  220. (rspamd_util:get_ticks() - t1) * 1000.0)
  221. collectgarbage()
  222. t1 = rspamd_util:get_ticks()
  223. reply.rows = data
  224. conn:send_ucl(reply)
  225. lua_util.debugm(N, task, 'process + sending took %s ms',
  226. (rspamd_util:get_ticks() - t1) * 1000.0)
  227. collectgarbage()
  228. else
  229. rspamd_logger.errx(task, 'got error %s when getting history: %s',
  230. err)
  231. conn:send_error(504, '{"error": "' .. err .. '"}')
  232. end
  233. end
  234. lua_redis.rspamd_redis_make_request(task,
  235. redis_params, -- connect params
  236. nil, -- hash key
  237. false, -- is write
  238. redis_lrange_cb, --callback
  239. 'LRANGE', -- command
  240. { prefix, string.format('%d', from), string.format('%d', to) }, -- arguments
  241. { opaque_data = true }
  242. )
  243. end
  244. end
  245. local opts = rspamd_config:get_all_opt('history_redis')
  246. if opts then
  247. settings = lua_util.override_defaults(settings, opts)
  248. local res, err = settings_schema:transform(settings)
  249. if not res then
  250. rspamd_logger.warnx(rspamd_config, '%s: plugin is misconfigured: %s', N, err)
  251. lua_util.disable_module(N, "config")
  252. return
  253. end
  254. settings = res
  255. if settings.compress then
  256. template_env.COMPRESS = '_zst'
  257. else
  258. template_env.COMPRESS = ''
  259. end
  260. redis_params = lua_redis.parse_redis_server('history_redis')
  261. if not redis_params then
  262. rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
  263. lua_util.disable_module(N, "redis")
  264. else
  265. rspamd_config:register_symbol({
  266. name = 'HISTORY_SAVE',
  267. type = 'idempotent',
  268. callback = history_save,
  269. flags = 'empty,explicit_disable,ignore_passthrough',
  270. augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) }
  271. })
  272. lua_redis.register_prefix(lua_util.jinja_template(settings.key_prefix, template_env, false, true), N,
  273. "Redis history", {
  274. type = 'list',
  275. })
  276. rspamd_plugins['history'] = {
  277. handler = handle_history_request
  278. }
  279. end
  280. end