HEX
Server: Apache/2
System: Linux nexus-01 4.18.0-553.146.1.el8_10.x86_64 #1 SMP Tue Jul 21 03:06:01 EDT 2026 x86_64
User: aglcoke (1118)
PHP: 8.2.32
Disabled: mail,exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname
Upload Files
File: //proc/thread-self/root/usr/share/rspamd/lualib/rspamadm/autolearnstats.lua
--[[
Copyright (c) 2026, Alexander Moisseev <moiseev@mezonplus.ru>

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 argparse = require "argparse"
local rspamd_regexp = require "rspamd_regexp"
local rspamd_util = require "rspamd_util"
local log_utils = require "lua_log_utils"
local ansicolors = require "ansicolors"

local parser = argparse()
  :name "rspamadm autolearnstats"
  :description "Report Bayes autolearn events from rspamd log"
  :help_description_margin(32)

parser:argument "log"
  :description "Log file or directory to read (stdin if omitted)"
  :args "?"
  :default ""
parser:option "--start"
  :description "Starting time for log parsing"
  :argname "<time>"
  :default ""
parser:option "--end"
  :description "Ending time for log parsing"
  :argname "<time>"
parser:option "-n --num-logs"
  :description "Number of recent logfiles to analyze"
  :argname "<n>"
  :convert(tonumber)
parser:option "-x --exclude-logs"
  :description "Number of latest logs to exclude"
  :argname "<n>"
  :default "0"
  :convert(tonumber)
parser:option "--sort-by"
  :description("Sort rows by column value, then by timestamp. " ..
    "Sorting is lexicographic except for 'score' (numeric).")
  :argname "<col>"
  :choices {"verdict", "score", "ts", "tid", "ip", "from", "rcpts"}
parser:flag "--group"
  :description("Insert a blank separator line between groups of rows " ..
    "with the same value in the --sort-by column")

-- Lua-side "can autolearn" log line (lua_bayes_learn.lua)
-- Module is always "lua" regardless of worker type.
-- Captures: req_id, from, verdict, score, op, threshold, mime_rcpts
local re_lua = rspamd_regexp.create(
  '/\\([^)]+\\) (<[0-9a-fA-F]+>); lua; lua_bayes_learn\\.lua:\\d+: ' ..
  'id: <[^>]*>, from: <([^>]*)>: can autolearn (\\w+): score (-?[\\d.]+) ' ..
  '([^\\s,]+) (-?[\\d.]+), mime_rcpts: <([^>]*)>/')

-- C-side "autolearn confirmed" line (rspamd_stat_check_autolearn).
-- Module is "proxy" for rspamd_proxy worker, "task" for normal worker.
-- Matched only against the success messages, which start with "<MSG-ID>: autolearn",
-- to avoid false positives from error messages emitted by the same function.
-- Captures: req_id
local re_confirmed = rspamd_regexp.create(
  '/\\([^)]+\\) (<[0-9a-fA-F]+>); \\w+; rspamd_stat_check_autolearn: <[^>]*>: autolearn /')

-- Task result line for sender IP extraction (rspamd_task_write_log)
-- Module is "proxy" for rspamd_proxy worker, "task" for normal worker.
-- Captures: req_id, ip
local re_ip = rspamd_regexp.create(
  '/\\([^)]+\\) (<[0-9a-fA-F]+>); \\w+; rspamd_task_write_log: ' ..
  'id: [^,]+,(?:\\s*qid: [^,]+,)?\\s*ip: ([^,\\s]+),/')

-- Color support: only when stdout is a TTY
local isatty = rspamd_util.isatty()

-- lua_bayes_learn.lua only logs "can autolearn" for spam, junk, and ham.
local verdict_colors = {
  spam = ansicolors.red,
  junk = ansicolors.yellow,
  ham  = ansicolors.green,
}

local function get_verdict_color(verdict)
  return verdict_colors[verdict] or ansicolors.white
end

local function colored(s, color)
  if not isatty or not color or color == '' then return s end
  return color .. s .. ansicolors.reset
end

local function pad(s, n)
  local len = #s
  if len >= n then return s end
  return s .. string.rep(' ', n - len)
end

local MAX_COL = 60

local function cell(s, n)
  if #s > n then s = s:sub(1, n - 2) .. '..' end
  return pad(s, n)
end

local function iterate_bayes_log(handle, start_time, end_time, candidates, learned, ips)
  local ts_format = nil

  for line in handle:lines() do
    log_utils.spinner()

    if not ts_format then
      local fmt = log_utils.detect_log_format(line)
      if fmt == false then
        io.stderr:write("Unknown log format\n")
        return
      elseif fmt == nil then
        goto continue
      else
        ts_format = fmt
      end
    end

    -- "can autolearn" line: collect candidate with full metadata
    local r = re_lua:search(line, false, true)
    if r and r[1] then
      local caps = r[1]
      local ts = log_utils.extract_timestamp(line, ts_format)
      if ts then
        if start_time ~= '' and ts < start_time then goto continue end
        if end_time and ts > end_time then goto continue end
        local req_id = tostring(caps[2])
        if candidates[req_id] then
          io.stderr:write(string.format(
            "Warning: duplicate req_id %s, overwriting earlier entry\n", req_id))
        end
        candidates[req_id] = {
          ts      = ts,
          from    = tostring(caps[3]),
          verdict = tostring(caps[4]),
          score   = tostring(caps[5]),
          op      = tostring(caps[6]),
          thr     = tostring(caps[7]),
          rcpts   = tostring(caps[8]),
        }
      end
      goto continue
    end

    -- "autolearn confirmed" line: mark request as actually learned
    r = re_confirmed:search(line, false, true)
    if r and r[1] then
      learned[tostring(r[1][2])] = true
      goto continue
    end

    -- Task result line: extract sender IP.
    -- No time-window filter here: only IPs for req_ids present in candidates
    -- (which are already time-filtered) will be used in the output.
    r = re_ip:search(line, false, true)
    if r and r[1] then
      ips[tostring(r[1][2])] = tostring(r[1][3])
    end

    ::continue::
  end
end

local function process_logs(log_file, start_time, end_time, candidates, learned, ips, opts)
  opts = opts or {}
  local num_logs = opts.num_logs
  local exclude_logs = opts.exclude_logs or 0

  start_time = log_utils.normalized_time(start_time or '')
  end_time = log_utils.normalized_time(end_time or '')
  if end_time == '' then end_time = nil end

  if not log_file or log_file == '-' or log_file == '' then
    iterate_bayes_log(io.stdin, start_time, end_time, candidates, learned, ips)
    return
  end

  local err, st = rspamd_util.stat(log_file)
  if err then
    io.stderr:write(string.format("Cannot stat %s: %s\n", log_file, err))
    os.exit(1)
  end

  if st.type == 'directory' then
    local logs = log_utils.get_logfiles_list(log_file, num_logs, exclude_logs)
    for idx, fname in ipairs(logs) do
      local path = log_file .. '/' .. fname
      local h, open_err = log_utils.open_log_file(path)
      if not h then
        io.stderr:write(string.format("Cannot open %s: %s\n", path, open_err or 'unknown'))
      else
        if isatty then
          io.stderr:write(string.format("\027[J  Parsing log files: [%d/%d] %s\027[G",
            idx, #logs, fname))
        else
          io.stderr:write(string.format("  Parsing log files: [%d/%d] %s\n",
            idx, #logs, fname))
        end
        log_utils.reset_spinner()
        iterate_bayes_log(h, start_time, end_time, candidates, learned, ips)
        h:close()
      end
    end
    if isatty then io.stderr:write("\027[J\027[G") end
  else
    local h, open_err = log_utils.open_log_file(log_file)
    if not h then
      io.stderr:write(string.format("Cannot open %s: %s\n", log_file, open_err or 'unknown'))
      os.exit(1)
    end
    log_utils.reset_spinner()
    iterate_bayes_log(h, start_time, end_time, candidates, learned, ips)
    h:close()
  end
end

local function make_sort_key_fns(ips)
  return {
    verdict = function(e) return e.c.verdict end,
    score   = function(e) return tonumber(e.c.score) or 0 end,
    ts      = function(e) return e.c.ts end,
    tid     = function(e) return e.req_id:gsub('[<>]', '') end,
    ip      = function(e) return ips[e.req_id] or '-' end,
    from    = function(e) return e.c.from end,
    rcpts   = function(e) return e.c.rcpts end,
  }
end

local function handler(args)
  local res = parser:parse(args)

  local candidates = {}
  local learned = {}
  local ips = {}

  -- res['end'] uses bracket syntax because 'end' is a Lua reserved keyword.
  process_logs(
    res['log'],
    res['start'] or '',
    res['end'] or '',
    candidates, learned, ips,
    {
      num_logs     = res['num_logs'],
      exclude_logs = res['exclude_logs'] or 0,
    }
  )

  local sort_by          = res['sort_by']
  local do_group         = res['group']
  local effective_sort   = sort_by or 'ts'
  local sort_key_fns     = make_sort_key_fns(ips)
  local key_fn           = sort_key_fns[effective_sort]

  local sorted = {}
  for req_id, c in pairs(candidates) do
    table.insert(sorted, { req_id = req_id, c = c })
  end

  -- Pre-compute sort keys: O(n) instead of O(n log n) calls inside comparator,
  -- and reused by the group separator logic during rendering.
  for _, entry in ipairs(sorted) do
    entry.sort_key = key_fn(entry)
  end

  if effective_sort ~= 'ts' then
    table.sort(sorted, function(a, b)
      if a.sort_key ~= b.sort_key then return a.sort_key < b.sort_key end
      return a.c.ts < b.c.ts
    end)
  else
    table.sort(sorted, function(a, b) return a.sort_key < b.sort_key end)
  end

  -- Compute column widths from actual data (plain values, no ANSI codes)
  local col = {
    verdict = #'Verd',
    score   = #'Score',
    ts      = 19,       -- timestamp format is always 19 chars
    tid     = #'Task',
    ip      = #'IP',
    from    = #'From',
    rcpts   = #'Recipients',
  }
  for _, entry in ipairs(sorted) do
    local c = entry.c
    col.verdict = math.max(col.verdict, #c.verdict)
    col.score   = math.max(col.score,   #(c.score .. c.op .. c.thr))
    col.tid     = math.max(col.tid,     #entry.req_id - 2) -- strip < >
    col.ip      = math.max(col.ip,      #(ips[entry.req_id] or '-'))
    col.from    = math.max(col.from,    #c.from)
    col.rcpts   = math.max(col.rcpts,   #c.rcpts)
  end
  col.from  = math.min(col.from,  MAX_COL)
  col.rcpts = math.min(col.rcpts, MAX_COL)

  local sep = '  '

  if #sorted == 0 then
    io.write('No autolearn candidates found.\n')
    return
  end

  local sep_width = 3 + #sep + col.verdict + #sep + col.score + #sep +
    col.ts + #sep + col.tid + #sep + col.ip + #sep + col.from + #sep + col.rcpts

  -- Header: [L]  Verd  Score  Timestamp  Task  IP  From  Recipients
  io.write(pad('', 3) .. sep .. pad('Verd', col.verdict) .. sep ..
    pad('Score', col.score) .. sep .. pad('Timestamp', col.ts) .. sep ..
    pad('Task', col.tid) .. sep .. pad('IP', col.ip) .. sep ..
    pad('From', col.from) .. sep .. 'Recipients\n')
  io.write(string.rep('-', sep_width) .. '\n')

  local n_learned = 0
  local class_stats = {}
  local prev_group_key = nil

  for _, entry in ipairs(sorted) do
    if do_group then
      if prev_group_key ~= nil and entry.sort_key ~= prev_group_key then
        io.write('\n')
      end
      prev_group_key = entry.sort_key
    end

    local req_id = entry.req_id
    local c = entry.c
    local tid     = req_id:gsub('[<>]', '')
    local score_str = c.score .. c.op .. c.thr
    local from_ip = ips[req_id] or '-'
    local is_learned = learned[req_id]
    local vcolor = get_verdict_color(c.verdict)

    if not class_stats[c.verdict] then
      class_stats[c.verdict] = { candidates = 0, learned = 0 }
    end
    class_stats[c.verdict].candidates = class_stats[c.verdict].candidates + 1
    if is_learned then
      n_learned = n_learned + 1
      class_stats[c.verdict].learned = class_stats[c.verdict].learned + 1
    end

    local marker = is_learned and '[L]' or '   '
    io.write(
      colored(pad(marker,    3),           is_learned and ansicolors.green or '') .. sep ..
      colored(pad(c.verdict, col.verdict), vcolor) .. sep ..
      pad(score_str, col.score) .. sep ..
      pad(c.ts,     col.ts)      .. sep ..
      pad(tid,      col.tid)     .. sep ..
      pad(from_ip,  col.ip)     .. sep ..
      cell(c.from,   col.from)  .. sep ..
      cell(c.rcpts,  col.rcpts) .. '\n'
    )
  end

  if #sorted > 0 then
    io.write(string.format("\nTotal autolearn candidates: %d  Learned: %d\n",
      #sorted, n_learned))

    local sorted_classes = {}
    for cls in pairs(class_stats) do
      table.insert(sorted_classes, cls)
    end
    table.sort(sorted_classes)

    local cls_label_w = 0
    for _, cls in ipairs(sorted_classes) do
      cls_label_w = math.max(cls_label_w, #cls)
    end
    for _, cls in ipairs(sorted_classes) do
      local s = class_stats[cls]
      io.write(colored(
        string.format("  %-" .. cls_label_w .. "s  %d candidates  /  %d learned\n",
          cls, s.candidates, s.learned),
        get_verdict_color(cls)))
    end
  end
end

-- Exposed for unit tests.
local exports = {
  handler = handler,
  description = parser._description,
  name = 'autolearnstats',
  _pad              = pad,
  _cell             = cell,
  _MAX_COL          = MAX_COL,
  _make_sort_key_fns = make_sort_key_fns,
}

return exports