]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] lua_extras: structured custom lua loader
authorVsevolod Stakhov <vsevolod@rspamd.com>
Tue, 5 May 2026 18:42:03 +0000 (19:42 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Tue, 5 May 2026 18:42:03 +0000 (19:42 +0100)
Add lualib/lua_extras with register_selector / register_map / register_regexp
helpers and a load_dir(cfg, dir, kind) directory loader. rules/rspamd.lua now
loads $LOCAL_CONFDIR/lua.local.d/{selectors,maps,regexps}/*.lua before
rspamd.local.lua, where each file returns a { name = def } table whose entries
are dispatched to the matching helper.

This lets distributions and add-ons ship custom selectors, maps and regexp
rules in well-typed files without touching rspamd.local.lua, which end users
may heavily modify. Existing free-form lua.local.d/*.lua at the root keeps
working unchanged. Errors in any single file are logged and skipped, never
aborting startup. Maps registered through the helper are stored in the global
rspamd_maps table, matching the existing lua_maps pattern.

Includes example.lua.example files in each subdirectory documenting the
expected file contract.

conf/lua.local.d/maps/example.lua.example [new file with mode: 0644]
conf/lua.local.d/regexps/example.lua.example [new file with mode: 0644]
conf/lua.local.d/selectors/example.lua.example [new file with mode: 0644]
lualib/lua_extras.lua [new file with mode: 0644]
rules/rspamd.lua

diff --git a/conf/lua.local.d/maps/example.lua.example b/conf/lua.local.d/maps/example.lua.example
new file mode 100644 (file)
index 0000000..2a72121
--- /dev/null
@@ -0,0 +1,24 @@
+-- Custom map definitions loaded by lua_extras at config time.
+--
+-- Each file in $LOCAL_CONFDIR/lua.local.d/maps/*.lua must return a table
+-- where keys are map names and values are the argument table accepted by
+-- rspamd_config:add_map{...}, see https://rspamd.com/doc/lua/config.html.
+--
+-- Registered maps are stored in the global `rspamd_maps` table so other
+-- lua code can look them up by name.
+
+return {
+  -- A simple set map loaded from a file (URL can also be http(s):// etc).
+  example_local_domains = {
+    type        = 'set',
+    description = 'Locally hosted domains',
+    url         = '${LOCAL_CONFDIR}/local_domains.list',
+  },
+
+  -- A regexp map of keys to string values.
+  example_brand_regexps = {
+    type        = 'regexp',
+    description = 'Brand name regexps',
+    url         = '${LOCAL_CONFDIR}/brand_regexps.list',
+  },
+}
diff --git a/conf/lua.local.d/regexps/example.lua.example b/conf/lua.local.d/regexps/example.lua.example
new file mode 100644 (file)
index 0000000..131fa54
--- /dev/null
@@ -0,0 +1,28 @@
+-- Custom regexp rules loaded by lua_extras at config time.
+--
+-- Each file in $LOCAL_CONFDIR/lua.local.d/regexps/*.lua must return a table
+-- where keys are symbol names and values are regexp rule definitions equivalent
+-- to assigning into config['regexp'][SYMBOL] from rspamd.local.lua.
+--
+-- See https://rspamd.com/doc/developers/writing_rules.html for the full DSL.
+
+local re_from_foo = 'From=/foo@/H'
+local re_subject_blah = '/blah/P'
+
+return {
+  EXAMPLE_FROM_FOO = {
+    re          = re_from_foo,
+    score       = 1.2,
+    description = 'Sender contains foo@',
+  },
+
+  EXAMPLE_FROM_FOO_NO_BLAH = {
+    re          = string.format('(%s) && !(%s)', re_from_foo, re_subject_blah),
+    score       = 0.5,
+    description = 'foo@ sender without "blah" in subject',
+    -- Optional condition gating the rule:
+    condition   = function(task)
+      return task:has_from('mime')
+    end,
+  },
+}
diff --git a/conf/lua.local.d/selectors/example.lua.example b/conf/lua.local.d/selectors/example.lua.example
new file mode 100644 (file)
index 0000000..7709935
--- /dev/null
@@ -0,0 +1,33 @@
+-- Custom selector definitions loaded by lua_extras at config time.
+--
+-- Each file in $LOCAL_CONFDIR/lua.local.d/selectors/*.lua must return a table
+-- where keys are selector names and values are either:
+--   * a function(task) returning the value, or
+--   * a full selector definition table:
+--       { get_value = function(task, args) ... end,
+--         description = '...',         -- optional
+--         type        = 'string',      -- optional, default 'string'
+--         args_schema = { ... } }      -- optional argument validation
+--
+-- Selectors registered here are available anywhere selectors are accepted
+-- (settings, multimap, ratelimit, redirector, classifiers, ...).
+
+return {
+  -- A trivial selector returning the message id with a fixed prefix.
+  example_msgid = function(task)
+    local mid = task:get_message_id()
+    if not mid then
+      return nil
+    end
+    return 'mid:' .. mid
+  end,
+
+  -- A selector with a description and explicit return type.
+  example_helo = {
+    description = 'SMTP HELO/EHLO string',
+    type        = 'string',
+    get_value   = function(task)
+      return task:get_helo()
+    end,
+  },
+}
diff --git a/lualib/lua_extras.lua b/lualib/lua_extras.lua
new file mode 100644 (file)
index 0000000..1a7a47d
--- /dev/null
@@ -0,0 +1,155 @@
+--[[
+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.
+]] --
+
+--[[[
+-- @module lua_extras
+-- Helpers and a directory loader for shipping custom selectors, maps and
+-- regexp rules from $LOCAL_CONFDIR/lua.local.d/{selectors,maps,regexps}/*.lua
+-- without touching rspamd.local.lua.
+--
+-- Each structured file is expected to `return` a table whose entries are
+-- registered with the matching helper. Errors in any single file are logged
+-- and do not abort startup.
+--]]
+
+local exports = {}
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local lua_selectors = require "lua_selectors"
+
+--[[[
+-- @function lua_extras.register_selector(cfg, name, def)
+-- Registers a selector extractor.
+-- `def` may be a function (treated as `get_value`) or a full selector table
+-- (`{ get_value = fn, description = '...', type = '...' }`).
+-- Returns true on success, false on error.
+--]]
+exports.register_selector = function(cfg, name, def)
+  if type(def) == 'function' then
+    def = { get_value = def }
+  end
+
+  if type(def) ~= 'table' or type(def.get_value) ~= 'function' then
+    rspamd_logger.errx(cfg, 'lua_extras: bad selector %s: expected function or table with get_value',
+        name)
+    return false
+  end
+
+  return lua_selectors.register_extractor(cfg, name, def)
+end
+
+--[[[
+-- @function lua_extras.register_map(cfg, name, args)
+-- Registers a map. `args` is the table accepted by rspamd_config:add_map().
+-- The created map object is stored as rspamd_maps[name] so it can be looked
+-- up by other lua code.
+-- Returns the map object on success, nil on error.
+--]]
+exports.register_map = function(cfg, name, args)
+  if type(args) ~= 'table' then
+    rspamd_logger.errx(cfg, 'lua_extras: bad map %s: expected table of add_map arguments', name)
+    return nil
+  end
+
+  rspamd_maps = rspamd_maps or {}
+
+  local ok, map_or_err = pcall(function()
+    return cfg:add_map(args)
+  end)
+
+  if not ok or not map_or_err then
+    rspamd_logger.errx(cfg, 'lua_extras: cannot add map %s: %s', name, map_or_err)
+    return nil
+  end
+
+  rspamd_maps[name] = map_or_err
+  return map_or_err
+end
+
+--[[[
+-- @function lua_extras.register_regexp(cfg, symbol, def)
+-- Registers a regexp rule by assigning it into config['regexp'][symbol],
+-- matching the rspamd.local.lua / *.lua pattern documented in
+-- conf/lua.local.d/module.lua.example.
+-- Returns true on success, false on error.
+--]]
+exports.register_regexp = function(cfg, symbol, def)
+  if type(def) ~= 'table' or type(def.re) ~= 'string' then
+    rspamd_logger.errx(cfg, 'lua_extras: bad regexp %s: expected table with `re` string', symbol)
+    return false
+  end
+
+  config = config or {}
+  config['regexp'] = config['regexp'] or {}
+
+  if config['regexp'][symbol] then
+    rspamd_logger.warnx(cfg, 'lua_extras: redefining regexp symbol %s', symbol)
+  end
+
+  config['regexp'][symbol] = def
+  return true
+end
+
+local kind_handlers = {
+  selectors = exports.register_selector,
+  maps = exports.register_map,
+  regexps = exports.register_regexp,
+}
+
+--[[[
+-- @function lua_extras.load_dir(cfg, dir, kind)
+-- Loads every *.lua file in `dir`, expecting each to return a table of
+-- { name = def } pairs. Each pair is dispatched to the helper for `kind`
+-- (one of 'selectors', 'maps', 'regexps'). Errors are logged and skipped.
+--]]
+exports.load_dir = function(cfg, dir, kind)
+  local handler = kind_handlers[kind]
+  if not handler then
+    rspamd_logger.errx(cfg, 'lua_extras: unknown kind %s for dir %s', kind, dir)
+    return
+  end
+
+  local files = rspamd_util.glob(dir .. '/*.lua') or {}
+  -- Stable ordering across platforms
+  table.sort(files)
+
+  for _, path in ipairs(files) do
+    local ok, chunk = pcall(loadfile, path)
+    if not ok or not chunk then
+      rspamd_logger.errx(cfg, 'lua_extras: cannot load %s: %s', path, chunk)
+    else
+      local run_ok, ret = pcall(chunk)
+      if not run_ok then
+        rspamd_logger.errx(cfg, 'lua_extras: error executing %s: %s', path, ret)
+      elseif type(ret) ~= 'table' then
+        rspamd_logger.warnx(cfg,
+            'lua_extras: %s did not return a table (kind=%s), skipped', path, kind)
+      else
+        for name, def in pairs(ret) do
+          if type(name) ~= 'string' then
+            rspamd_logger.errx(cfg,
+                'lua_extras: %s contains non-string key (kind=%s), skipped entry', path, kind)
+          else
+            handler(cfg, name, def)
+          end
+        end
+      end
+    end
+  end
+end
+
+return exports
index 59b5ed505b47628754e013084662c48787e5d69f..d9fbf40b658617af8bc3625d5cf5689833e9a795 100644 (file)
@@ -44,6 +44,13 @@ dofile(local_rules .. '/content.lua')
 dofile(local_rules .. '/fuzzy_html_phishing.lua')
 dofile(local_rules .. '/controller/init.lua')
 
+-- Structured custom code: lua.local.d/{selectors,maps,regexps}/*.lua are loaded
+-- before rspamd.local.lua so end-user customisation can still override them.
+local lua_extras = require "lua_extras"
+for _, kind in ipairs({ 'selectors', 'maps', 'regexps' }) do
+  lua_extras.load_dir(rspamd_config, local_conf .. '/lua.local.d/' .. kind, kind)
+end
+
 if rspamd_util.file_exists(local_conf .. '/rspamd.local.lua') then
   dofile(local_conf .. '/rspamd.local.lua')
 else