Свои правила и Lua-скрипты в rspamd: кастомные symbols, score и локальные white/blacklist без боли
Как писать кастомные symbols на Lua и regexp, не ломая байесовский баланс, где реально задавать score, почему whitelist и blacklist — это multimap, а не Lua-колбэк, и в какой слой конфига класть правило, чтобы оно пережило apt upgrade.
EvilMail Team21 июля 2026 г.13 мин чтения
Классика жанра из тикета, который я разгребал прошлой зимой: кто-то залогинился на почтовик, открыл /etc/rspamd/rspamd.conf, дописал туда symbol "BLOCK_BADGUY" { score = 999; }, обрадовался и ушёл. Через две недели прилетел apt upgrade, пакет rspamd перезаписал rspamd.conf дефолтом — правило испарилось. А ещё раньше та самая девятисотка успела отправить в reject легитимную транзакционную рассылку клиента: score сложился с сетевыми symbols и пробил порог на ровном месте.
В rspamd так не лечат. Здесь симптом снимается не грубой силой score, а точечными сигналами в правильном слое конфига. Кастомный symbol — это не приговор, а маленький композируемый вклад в общую метрику: 1–4 балла на «мягкий» признак. А whitelist и blacklist почти никогда не пишутся на Lua — для них есть multimap с горячей перезагрузкой карт. Разберём по слоям.
Иерархия конфигов: где вообще писать правила
Первое, что ломает продакшн, — непонимание, куда класть файл. В rspamd три рабочих слоя, и мёржатся они по-разному.
rspamd: кастомные Lua-правила, score и multimap white/blacklist — практика — EvilMail Blog
/etc/rspamd/local.d/*.conf — мягкий мёрдж. Файл дополняет и переопределяет отдельные ключи** внутри секции, оставляя остальное из дефолта. 90% ваших правок живут здесь: groups.conf, actions.conf, multimap.conf, regexp.conf.
/etc/rspamd/override.d/*.conf** — жёсткая замена. Файл выкидывает секцию целиком и подставляет вашу. Нужен редко — когда дефолт мешает и мягкого мёрджа не хватает.
`/etc/rspamd/rspamd.local.lua` — точка входа для Lua. Подключается автоматически из rspamd.conf; здесь живут register_symbol, add_map, register_dependency.
Карты (файлы со списками доменов, IP, регэкспов) кладём в `/etc/rspamd/maps.d/`. А сам /etc/rspamd/rspamd.conf и любые *.conf в корне /etc/rspamd/ — не трогаем никогда: их перезапишет пакетный менеджер при первом же обновлении.
Убедиться, что ваш файл реально сложился в итоговый конфиг, можно смёрдженным дампом:
bash
rspamadm configdump # весь эффективный конфиг
rspamadm configdump multimap # только секция multimap
rspamadm configdump actions # пороги действий
И железное правило: перед каждым reload прогоняйте rspamadm configtest. Красный configtest на живом воркере — это остановленная фильтрация и очередь на входе.
Первый кастомный symbol на Lua
Symbol регистрируется через rspamd_config:register_symbol в rspamd.local.lua. Живой пример — детект несовпадения домена в From и Reply-To. Классический фишинговый паттерн: письмо якобы от [email protected], а ответ уходит на [email protected].
lua
rspamd_config:register_symbol({
name = 'EM_REPLYTO_MISMATCH',
score = 3.0,
group = 'headers',
description = 'From и Reply-To на разных доменах',
type = 'normal',
callback = function(task)
local from = task:get_from('mime')
if not from or not from[1] or not from[1]['domain'] then return false end
local rt = task:get_header('Reply-To')
if not rt then return false end
local rt_dom = rt:match('@([%w%.%-]+)')
if rt_dom and rt_dom:lower() ~= from[1]['domain']:lower() then
return true, 1.0, rt_dom
end
return false
end
})
Что здесь важно по полям:
`type` — normal (обычный колбэк), virtual (навешивается на родительский символ), prefilter/postfilter (выполняется до/после основной фильтрации), callback (обработчик без собственного score). Для большинства правил — normal.
`callback` возвращает return true[, множитель][, опция]. Множитель 1.0 — «весь score символа»; можно вернуть, скажем, 0.5 для частичного срабатывания. Третий аргумент — опция, попадает в лог и в отчёт (rt_dom из примера покажет, на какой домен утекал ответ).
`one_shot = true` — если нужно, чтобы symbol не суммировался многократно на письме с несколькими совпадениями.
Из task API чаще всего пригождается: task:get_from('mime'|'smtp') возвращает таблицу с полями .addr, .user, .domain; task:get_header(name); task:get_received_headers(); task:insert_result(symbol, weight, option) для ручной вставки результата. Логирование внутри колбэка — rspamd_logger.infox(task, 'reply-to leak to %s', rt_dom).
Главный нюанс: score = 3.0 в register_symbol — это дефолт, а не финальное значение. Финальный score держите в groups.conf (об этом ниже), чтобы вся балансировка была в одном месте, а не размазана по Lua.
Regexp-правила без единой строки Lua
Для 80% кейсов Lua не нужен и вреден: колбэк на Lua исполняется на каждом письме, а нативные regexp-правила rspamd компилирует через Hyperscan и гоняет пачкой — это в разы дешевле по CPU. Regexp-правило описывается в local.d/regexp.conf или через config['regexp'] в Lua:
lua
config['regexp']['EM_FAKE_UNSUB'] = {
re = 'Subject=/discount|скидк/i{header} & !List-Unsubscribe=/./{header}',
score = 2.5,
description = 'Промо-тема без List-Unsubscribe',
}
Выражение комбинирует атомы логическими операторами & (И), | (ИЛИ), ! (НЕ) и скобками. Суффикс в фигурных скобках задаёт тип матча: {header} — по декодированному заголовку, {raw_header} — по сырому, {mime} — по MIME-части тела, {url} — по ссылкам из письма. Пример выше срабатывает, когда тема промо-шная, но заголовка List-Unsubscribe нет, — типичный признак серой рассылки, от которой не дают отписаться.
Когда несколько symbols бьют в одно и то же и дублируют баллы, их гасят композитами в local.d/composites.conf. Композит склеивает набор symbols в один вердикт и позволяет, например, обнулить вклад дочерних правил, оставив только сумму родительского. Это спасает от ситуации, когда три ваших правила ловят один и тот же фишинг и втроём загоняют письмо в reject.
Score, группы и пороги действий
Ключевая секция про «не ломай баланс». Метрика default суммирует score всех сработавших symbols и сравнивает сумму с порогами действий. Кастомный symbol — это вклад в сумму, а не самостоятельный приговор.
Финальный score задаётся в local.d/groups.conf — в секции symbols внутри группы:
group "headers" {
symbols {
"EM_REPLYTO_MISMATCH" {
score = 3.0;
description = "From и Reply-To на разных доменах";
}
}
}
Пороги действий живут в local.d/actions.conf. По умолчанию (значения зависят от сборки, всегда сверяйтесь через rspamadm configdump actions) это примерно: greylist около 4, add_header около 6, rewrite_subject около 8, reject около 15.
Правило большого пальца по величине score для кастомных сигналов: 1–4 балла на мягкий эвристический признак (несовпадение заголовков, подозрительная тема, странный X-Mailer) и 6+ только на железобетонные — то, в чём вы уверены на 100%. Одиночное правило, которое само по себе тащит письмо за 15, — почти всегда ошибка проектирования.
И отдельно: не перебивайте вслепую score байесовских, DKIM/DMARC и RBL-symbols. Это откалиброванные сигналы, завязанные на статистику и внешние источники. Поставив BAYES_SPAM в score = 0.5, вы не «смягчите ложные срабатывания» — вы выключите обучаемый классификатор и через месяц получите поток спама.
Локальный whitelist и blacklist — это multimap, а не Lua
Главный анти-паттерн. Списки «этих пускаем, этих режем» не пишутся на Lua-колбэках. Для них есть multimap — модуль, который читает карты из файлов и сам следит за их изменением. Конфигурируем в local.d/multimap.conf:
Горячая перезагрузка — то, ради чего всё и затевалось: rspamd отслеживает mtime файла карты и подхватывает изменения без reload и без рестарта воркеров. Добавили домен в whitelist, сохранили файл — через несколько секунд оно работает. Никаких systemctl reload, никаких оборванных сессий. Именно поэтому blacklist на Lua-колбэке — регресс: чтобы поменять список, придётся править код и перезагружать конфиг, роняя активные проверки.
Lua для карт нужен только в динамических сценариях — когда список подтягивается по HTTP или лежит в Redis:
lua
local bl = rspamd_config:add_map({
url = 'http://maps.internal/blacklist.txt',
type = 'set',
description = 'Динамический blacklist по HTTP',
})
Поддерживаются type = set/radix/regexp/map, источники http://, https://, redis. А порядок выполнения symbols, если один зависит от результата другого, задаётся через rspamd_config:register_dependency.
Тестирование, деплой и наблюдаемость
Прогон реального письма — основной инструмент отладки. Скармливаем .eml в rspamc и читаем секцию symbols с итоговым score и action:
bash
rspamc < message.eml # полный отчёт с symbols
rspamc symbols < message.eml # только сработавшие symbols
В выводе видно каждый symbol, его вклад, вашу опцию из колбэка и финальную строку Action: no action / add header / reject. Если кастомный symbol не появился — он либо не сработал по логике, либо не зарегистрировался (смотрите configtest и лог старта).
Деплой-цикл, который не роняет прод:
bash
rspamadm configtest # обязательно, до всего
rspamadm control reload # мягкая перезагрузка конфига и карт
control reload перечитывает конфигурацию без рестарта воркеров — активные SMTP-сессии не рвутся. systemctl restart rspamd в проде — это грубо: вы обрываете все текущие проверки. Рестарт нужен только при обновлении самого пакета.
Байес обучается вручную и автоматически: rspamc learn_spam < spam.eml и rspamc learn_ham < good.eml, плюс autolearn по порогам в local.d/classifier-bayes.conf. Веб-интерфейс контроллера на порту 11334 даёт историю проверок, симуляцию письма и раскладку по symbols — удобно, когда нужно объяснить клиенту, почему его письмо ушло в greylist. Логи хитов — в /var/log/rspamd/rspamd.log, туда же с привязкой к message-id падают ваши rspamd_logger.infox(task, ...) из колбэков.
Чеклист перед выкладкой в прод
Правило лежит в local.d/, override.d/ или rspamd.local.lua — не в корне /etc/rspamd/.
rspamadm configtest зелёный.
Score кастомного symbol в разумном диапазоне: 1–4 на мягкий признак, 6+ только на железобетонный.
Финальный score задан в groups.conf, а не размазан по Lua.
Whitelist/blacklist ушли в multimap, а не в Lua-колбэк.
Файлы карт лежат в maps.d/ в корректном формате (plain set / radix / regexp).
Прогнал минимум одно spam- и одно ham-письмо через rspamc и проверил итоговый action.
Убедился, что новый symbol не даёт ложный reject на собственной легитимной рассылке.
Не тронул score байесовских, DKIM/DMARC и RBL symbols.
Перезагрузка сделана через rspamadm control reload, а не рестартом воркеров.
Rspamd наказывает за мышление в стиле SpamAssassin — «один гигантский ruleset и большие score». Награждает за обратное: маленькие композируемые сигналы, правильный слой конфига и списки в multimap. Правило, собранное по этим принципам, переживёт любой apt upgrade и не завалит вашу же рассылку в reject посреди ночи.