From: Vsevolod Stakhov Date: Tue, 5 May 2026 18:42:03 +0000 (+0100) Subject: [Feature] lua_extras: structured custom lua loader X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=315bb47768483e4cd4abfc43cca69b4a7e005d86;p=thirdparty%2Frspamd.git [Feature] lua_extras: structured custom lua loader 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. --- diff --git a/conf/lua.local.d/maps/example.lua.example b/conf/lua.local.d/maps/example.lua.example new file mode 100644 index 0000000000..2a721218cd --- /dev/null +++ b/conf/lua.local.d/maps/example.lua.example @@ -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 index 0000000000..131fa54ef2 --- /dev/null +++ b/conf/lua.local.d/regexps/example.lua.example @@ -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 index 0000000000..7709935b4a --- /dev/null +++ b/conf/lua.local.d/selectors/example.lua.example @@ -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 index 0000000000..1a7a47dedc --- /dev/null +++ b/lualib/lua_extras.lua @@ -0,0 +1,155 @@ +--[[ +Copyright (c) 2026, 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. +]] -- + +--[[[ +-- @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 diff --git a/rules/rspamd.lua b/rules/rspamd.lua index 59b5ed505b..d9fbf40b65 100644 --- a/rules/rspamd.lua +++ b/rules/rspamd.lua @@ -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