HEX
Server: Apache/2
System: Linux nexus-01 4.18.0-553.120.1.el8_10.x86_64 #1 SMP Mon Apr 20 18:04:27 EDT 2026 x86_64
User: aglcoke (1118)
PHP: 8.2.31
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: //usr/share/rspamd/lualib/lua_log_utils.lua
--[[
Copyright (c) 2026, Vsevolod Stakhov <vsevolod@rspamd.com>

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_regexp = require "rspamd_regexp"
local rspamd_util = require "rspamd_util"

local exports = {}

local isatty = rspamd_util.isatty()

local decompressor = {
  bz2 = 'bzip2 -cd',
  gz  = 'gzip -cd',
  xz  = 'xz -cd',
  zst = 'zstd -cd',
}

local month_map = {
  Jan = 0, Feb = 1, Mar = 2, Apr = 3, May = 4, Jun = 5,
  Jul = 6, Aug = 7, Sep = 8, Oct = 9, Nov = 10, Dec = 11,
}

local spinner_chars = { '/', '-', '\\', '|' }
local spinner_update_time = 0

function exports.spinner()
  if not isatty then
    return
  end
  local now = os.time()
  if (now - spinner_update_time) < 1 then
    return
  end
  spinner_update_time = now
  io.stderr:write(string.format("%s\r", spinner_chars[(now % #spinner_chars) + 1]))
  io.stderr:flush()
end

function exports.reset_spinner()
  spinner_update_time = 0
end

local re_rspamd_fmt = rspamd_regexp.create(
  '^\\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d(?:\\.\\d{3,5})? #\\d+\\(')
local re_syslog_fmt1 = rspamd_regexp.create(
  '^\\w{3} \\s?\\d\\d? \\d\\d:\\d\\d:\\d\\d #\\d+\\(')
local re_syslog_fmt2 = rspamd_regexp.create(
  '^\\w{3} \\s?\\d\\d? \\d\\d:\\d\\d:\\d\\d \\S+ rspamd\\[\\d+\\]')
local re_syslog5424_fmt = rspamd_regexp.create(
  '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,6})?(?:Z|[-+]\\d{2}:\\d{2}) \\S+ rspamd\\[\\d+\\]')
local re_newsyslog = rspamd_regexp.create(
  '^\\w{3} \\s?\\d\\d? \\d\\d:\\d\\d:\\d\\d \\S+ newsyslog\\[\\d+\\]: logfile turned over$')
local re_journalctl = rspamd_regexp.create(
  '^-- Logs begin at \\w{3} \\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d [A-Z]{3},' ..
  ' end at \\w{3} \\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d [A-Z]{3}\\. --$')

function exports.detect_log_format(line)
  if re_rspamd_fmt:match(line) then
    return 'rspamd'
  elseif re_syslog_fmt1:match(line) or re_syslog_fmt2:match(line) then
    return 'syslog'
  elseif re_syslog5424_fmt:match(line) then
    return 'syslog5424'
  elseif re_newsyslog:match(line) or re_journalctl:match(line) then
    return nil -- skip line
  else
    return false -- unknown
  end
end

function exports.syslog2iso(ts_str)
  local month_s, day, hh, mm, ss = ts_str:match('^(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)')
  if not month_s then
    return nil
  end
  local mon = month_map[month_s]
  if not mon then
    return nil
  end
  local now = os.time()
  local t = os.date('*t', now)
  local year = t.year
  local epoch = os.time({
    year = year, month = mon + 1, day = tonumber(day),
    hour = tonumber(hh), min = tonumber(mm), sec = tonumber(ss)
  })
  if epoch > now then
    year = year - 1
  end
  return string.format('%04d-%02d-%02d %02d:%02d:%02d',
    year, mon + 1, tonumber(day), tonumber(hh), tonumber(mm), tonumber(ss))
end

function exports.extract_timestamp(line, ts_format)
  if ts_format == 'syslog' then
    local ts_str = line:match('^(%a+%s+%d+%s+%d+:%d+:%d+)')
    if ts_str then
      return exports.syslog2iso(ts_str)
    end
  elseif ts_format == 'syslog5424' then
    local date, time = line:match('^(%d%d%d%d%-%d%d%-%d%d)T(%d%d:%d%d:%d%d)')
    if date and time then
      return date .. ' ' .. time
    end
  else
    local d, t = line:match('^(%d%d%d%d%-%d%d%-%d%d)%s+(%d%d:%d%d:%d%d)')
    if d and t then
      return d .. ' ' .. t
    end
  end
  return nil
end

function exports.normalized_time(s)
  if not s or s == '' then
    return ''
  end
  if s:match('^%d%d[:%d]*$') then
    local t = os.date('*t')
    return string.format('%04d-%02d-%02d %s', t.year, t.month, t.day, s)
  end
  return s
end

local function shell_quote(s)
  return "'" .. s:gsub("'", "'\\''") .. "'"
end

function exports.open_log_file(path)
  local ext = path:match('%.([^%.]+)$')
  local dc = decompressor[ext]
  if dc then
    return io.popen(dc .. ' ' .. shell_quote(path), 'r')
  else
    return io.open(path, 'r')
  end
end

local re_numbered_log = rspamd_regexp.create([[\.\d+(?:\.(?:bz2|gz|xz|zst))?$]])

local function numeric_index(fname)
  local idx = fname:match('%.(%d+)%.')
  if not idx then
    idx = fname:match('%.(%d+)$')
  end
  return tonumber(idx) or 0
end

function exports.get_logfiles_list(dir, num_logs, exclude_logs)
  exclude_logs = exclude_logs or 0

  local all_files = rspamd_util.glob(dir .. '/*')
  if not all_files or #all_files == 0 then
    io.stderr:write(string.format("No files found in directory: %s\n", dir))
    return {}
  end

  local unnumbered = {}
  local numbered = {}

  for _, full_path in ipairs(all_files) do
    local err, st = rspamd_util.stat(full_path)
    if not err and st and st.type == 'regular' then
      local fname = full_path:match('[^/]+$')
      if re_numbered_log:match(fname) then
        table.insert(numbered, fname)
      else
        table.insert(unnumbered, fname)
      end
    end
  end

  table.sort(numbered, function(a, b)
    return numeric_index(a) < numeric_index(b)
  end)

  local logs = {}
  for _, f in ipairs(unnumbered) do
    table.insert(logs, f)
  end
  for _, f in ipairs(numbered) do
    table.insert(logs, f)
  end

  -- Apply exclude_logs and num_logs (splice from exclude_logs+1, take num_logs)
  local start_idx = exclude_logs + 1
  local end_idx = num_logs and (start_idx + num_logs - 1) or #logs
  if end_idx > #logs then
    end_idx = #logs
  end

  local selected = {}
  for i = start_idx, end_idx do
    table.insert(selected, logs[i])
  end

  -- Reverse order (newest last -> oldest first for processing)
  local reversed = {}
  for i = #selected, 1, -1 do
    table.insert(reversed, selected[i])
  end

  io.stderr:write("\nLog files to process:\n")
  for _, f in ipairs(reversed) do
    io.stderr:write(string.format("  %s\n", f))
  end
  io.stderr:write("\n")

  return reversed
end

local re_task_log = rspamd_regexp.create([[rspamd_task_write_log]])
local re_log_line = rspamd_regexp.create(
  '/\\(([^()]+)\\): \\[(NaN|-?\\d+(?:\\.\\d+)?)\\/(-?\\d+(?:\\.\\d+)?)\\]\\s+\\[([^]]+)\\].*time: (\\d+\\.\\d+)ms/')

function exports.iterate_log(handle, start_time, end_time, callback, opts)
  opts = opts or {}
  local search_pattern = opts.search_pattern
  local search_re
  if search_pattern and search_pattern ~= '' then
    search_re = rspamd_regexp.create(search_pattern)
  end

  local ts_format = nil
  local enabled = (not search_re)

  for line in handle:lines() do
    if not ts_format then
      local fmt = exports.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

    if not enabled then
      if search_re and search_re:match(line) then
        enabled = true
      else
        goto continue
      end
    end

    if re_task_log:match(line) then
      exports.spinner()

      local ts = exports.extract_timestamp(line, ts_format)
      if not ts then
        goto continue
      end

      if start_time ~= '' and ts < start_time then
        goto continue
      end
      if end_time and end_time ~= '' and ts > end_time then
        goto continue
      end

      local results = re_log_line:search(line, false, true)
      if not results or #results == 0 then
        goto continue
      end

      local captures = results[1]
      if not captures or #captures < 6 then
        goto continue
      end

      local act = tostring(captures[2])
      local score_str = tostring(captures[3])
      local symbols_str = tostring(captures[5])
      local scan_time_str = tostring(captures[6])

      local score = tonumber(score_str) or 0
      local scan_time = tonumber(scan_time_str) or 0

      callback(ts, act, score, symbols_str, scan_time, line)
    end

    ::continue::
  end
end

function exports.process_logs(log_file, start_time, end_time, callback, opts)
  opts = opts or {}
  local num_logs = opts.num_logs
  local exclude_logs = opts.exclude_logs or 0

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

  if not log_file or log_file == '-' or log_file == '' then
    exports.iterate_log(io.stdin, start_time, end_time, callback, opts)
  else
    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 = exports.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 = exports.open_log_file(path)
        if not h then
          io.stderr:write(string.format("Cannot open %s: %s\n", path, open_err or 'unknown error'))
        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
          exports.reset_spinner()
          exports.spinner()
          exports.iterate_log(h, start_time, end_time, callback, opts)
          h:close()
        end
      end
      if isatty then
        io.stderr:write("\027[J\027[G")
      end
    else
      local h, open_err = exports.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 error'))
        os.exit(1)
      end
      exports.reset_spinner()
      exports.spinner()
      exports.iterate_log(h, start_time, end_time, callback, opts)
      h:close()
    end
  end
end

return exports