Почтовый сервер на FreeBSD 15.1: Exim и Dovecot: различия между версиями
Bas (обсуждение | вклад) Пошаговая установка Exim и Dovecot 2.4 на FreeBSD 15.1: UFS /opt, домены, алиасы, TLS, DKIM, SnappyMail, ClamAV и Alloy |
Bas (обсуждение | вклад) Аудит публикации: заменить LAN-адреса документационными TEST-NET, уточнить примеры; секретов не обнаружено |
||
| (не показаны 3 промежуточные версии этого же участника) | |||
| Строка 3: | Строка 3: | ||
Самостоятельная инструкция для одного сервера: Exim принимает SMTP и submission, Dovecot предоставляет IMAPS, а доставка в Maildir выполняется через локальный LMTP-сокет. Пользователи и алиасы хранятся в файлах, SQL не требуется. Почта, очередь и дополнительные данные размещаются на отдельной UFS в /opt. Сертификат выпускается после подготовки конфигураций: до этого сетевые почтовые службы не запускаются. | Самостоятельная инструкция для одного сервера: Exim принимает SMTP и submission, Dovecot предоставляет IMAPS, а доставка в Maildir выполняется через локальный LMTP-сокет. Пользователи и алиасы хранятся в файлах, SQL не требуется. Почта, очередь и дополнительные данные размещаются на отдельной UFS в /opt. Сертификат выпускается после подготовки конфигураций: до этого сетевые почтовые службы не запускаются. | ||
'''Все имена и адреса ниже — примеры.''' Заменить example.org и example.net своими доменами, mail.example.org — своим FQDN, 192.0.2.10 — реальным публичным IPv4, | '''Все имена и адреса ниже — примеры.''' Заменить example.org и example.net своими доменами, mail.example.org — своим FQDN, 192.0.2.10 — реальным публичным IPv4, 198.51.100.20 — адресом сервера в LAN. 192.0.2.0/24 и 198.51.100.0/24 зарезервированы для документации (RFC 5737) и не являются рабочими адресами инфраструктуры. В примерах 198.51.100.20 — условный LAN-адрес сервера, 198.51.100.1 — условный шлюз; заменить их своими значениями. Домены example.org и example.net также предназначены для примеров. Публичные адреса документации, поставщиков и источников баз приведены намеренно; учётные данные для доступа к ним не публикуются. Команды для чистой системы выполняются от root в /bin/sh; существующие конфигурации сначала сравнить, не заменять вслепую. Проверенная основа: FreeBSD 15.1, Exim 4.100, Dovecot 2.4.5. Для других веток Dovecot синтаксис отличается. | ||
__TOC__ | __TOC__ | ||
| Строка 51: | Строка 51: | ||
sysrc hostname=mail.example.org | sysrc hostname=mail.example.org | ||
hostname mail.example.org | hostname mail.example.org | ||
sysrc defaultrouter= | sysrc defaultrouter=198.51.100.1 | ||
# Только если в работающей таблице отсутствует default route: | # Только если в работающей таблице отсутствует default route: | ||
route add default  | route add default 198.51.100.1 | ||
</pre> | </pre> | ||
| Строка 149: | Строка 149: | ||
qualify_domain = example.org | qualify_domain = example.org | ||
keep_environment = | keep_environment = | ||
local_interfaces = 127.0.0.1 :  | local_interfaces = 127.0.0.1 : 198.51.100.20 | ||
disable_ipv6 = true | disable_ipv6 = true | ||
domainlist local_domains = @ : /usr/local/etc/exim/domains | domainlist local_domains = @ : /usr/local/etc/exim/domains | ||
| Строка 1202: | Строка 1202: | ||
    server { |     server { | ||
        listen 127.0.0.1:80; |         listen 127.0.0.1:80; | ||
        listen  |         listen 198.51.100.20:80; | ||
        server_name mail.example.org; |         server_name mail.example.org; | ||
        return 308 https://mail.example.org$request_uri; |         return 308 https://mail.example.org$request_uri; | ||
| Строка 1209: | Строка 1209: | ||
    server { |     server { | ||
        listen 127.0.0.1:443 ssl; |         listen 127.0.0.1:443 ssl; | ||
        listen  |         listen 198.51.100.20:443 ssl; | ||
        server_name mail.example.org; |         server_name mail.example.org; | ||
        ssl_certificate /usr/local/etc/mail-certs/fullchain.pem; |         ssl_certificate /usr/local/etc/mail-certs/fullchain.pem; | ||
| Строка 1275: | Строка 1275: | ||
# /usr/local/etc/mail-certs/privkey.pem   -> /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem | # /usr/local/etc/mail-certs/privkey.pem   -> /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem | ||
# Keep the private key root-owned, mode 0600, on the front server. | # Keep the private key root-owned, mode 0600, on the front server. | ||
# The front nginx must be able to reach  | # The front nginx must be able to reach 198.51.100.20 TCP 443. | ||
server { | server { | ||
| Строка 1293: | Строка 1293: | ||
    location / { |     location / { | ||
        proxy_pass https:// |         proxy_pass https://198.51.100.20; | ||
        proxy_http_version 1.1; |         proxy_http_version 1.1; | ||
        proxy_set_header Host mail.example.org; |         proxy_set_header Host mail.example.org; | ||
| Строка 1316: | Строка 1316: | ||
} | } | ||
</pre> | </pre> | ||
Перед reload выполнить nginx -t. В этой схеме NAT 443 указывает на frontend, а он должен достигать | Перед reload выполнить nginx -t. В этой схеме NAT 443 указывает на frontend, а он должен достигать 198.51.100.20:443 по LAN. Порты SMTP/IMAP идут непосредственно на почтовый сервер. | ||
== 12. Необязательно: ClamAV и проверка SMTP-писем == | == 12. Необязательно: ClamAV и проверка SMTP-писем == | ||
| Строка 1475: | Строка 1475: | ||
openssl x509 -in /usr/local/etc/mail-certs/fullchain.pem -noout -dates -subject | openssl x509 -in /usr/local/etc/mail-certs/fullchain.pem -noout -dates -subject | ||
</pre> | </pre> | ||
Не публиковать users, приватные DKIM-ключи, TLS-ключ, секреты Alloy и содержимое пользовательских писем. Следить за пространством отдельно на / и /opt. Запланировать продление сертификата до срока истечения; после обновления проверить все его потребители, включая внешний HTTPS frontend. Перед заменой конфигураций сравнивать действующее состояние. Rspamd с обучением | Не публиковать users, приватные DKIM-ключи, TLS-ключ, секреты Alloy и содержимое пользовательских писем. Следить за пространством отдельно на / и /opt. Запланировать продление сертификата до срока истечения; после обновления проверить все его потребители, включая внешний HTTPS frontend. Перед заменой конфигураций сравнивать действующее состояние. Настройка Rspamd с автоматическим обучением при перемещениях INBOX ↔ Junk приведена в разделе 15; этот дополнительный этап выполняется после проверки базовой доставки. | ||
== 15. Rspamd: оценка почты и автоматическое обучение через IMAP == | |||
Этот дополнительный этап выполняется после работоспособной базовой установки Exim и Dovecot 2.4. Пакеты: Rspamd 4.1, Redis, dovecot24-pigeonhole и Python 3.12. Пользователи продолжают храниться в файлах; Redis используется для статистики Rspamd, а не как каталог почтовых ящиков. Данные и очередь обратной связи размещаются на UFS /opt. | |||
Схема рассчитана на общую Bayes-базу всех доменов. Любой пользователь, имеющий доступ к ящику, влияет на её обучение. Настройка autolearn = false отключает обучение по собственным оценкам Rspamd; обучение по явно выполненному пользователем IMAP-действию выполняет отдельная служба. | |||
* Exim очищает внешние X-Spam-заголовки, оценивает только неаутентифицированную SMTP-почту и добавляет локальную оценку. | |||
* Действие Rspamd reject здесь является рекомендацией: по антиспаму нет SMTP-отказов, карантина или удаления. Недоступность Rspamd не блокирует доставку. | |||
* Уже установленный ClamAV продолжает работать по прежней политике: обнаружение вируса отклоняет письмо, отказ сканера даёт временный SMTP-ответ. | |||
* INBOX → Junk создаёт spam-событие; Junk → INBOX — ham-событие. IMAPSieve использует причину COPY как для IMAP MOVE, так и для прямого IMAP COPY между этими папками. APPEND, изменение флагов, Junk → Trash и другие папки не обучают фильтр. | |||
* IMAP-процесс только пишет ограниченное событие на диск. Пароль контроллера и сетевой вызов rspamc находятся в отдельном root-процессе. | |||
=== 15.1. Установить пакеты и подготовить данные === | |||
Команды ниже относятся к новой установке выделенных Redis/Rspamd. Если Redis уже обслуживает другие приложения, не заменять его параметры: выделить отдельный экземпляр и согласованно изменить адрес подключения. Выполнять от root в /bin/sh. Проверить доступность /opt и план пакетного менеджера. | |||
<pre> | |||
mount -p | awk '$2 == "/opt" && $3 == "ufs" { ok=1 } END { exit !ok }' | |||
pkg install -n rspamd redis dovecot24-pigeonhole python312 | |||
pkg install -y rspamd redis dovecot24-pigeonhole python312 | |||
sysrc redis_enable=NO rspamd_enable=NO rspamd_feedback_enable=NO | |||
service rspamd onestop | |||
service redis onestop | |||
pkg info rspamd redis dovecot24-pigeonhole python312 | |||
install -d -o root -g wheel -m 0755 /opt/mail/log | |||
</pre> | |||
Если новые службы ещё не запущены, onestop может сообщить об отсутствии процесса; проверить sockstat. Следующее перемещение делается ровно один раз: целевые каталоги должны отсутствовать. Пакетные данные сохраняются на новом разделе, а штатные пути становятся ссылками. На уже настроенной системе этот блок не повторять. | |||
<pre> | |||
test -d /var/db/rspamd && test ! -L /var/db/rspamd || exit 1 | |||
test -d /var/log/rspamd && test ! -L /var/log/rspamd || exit 1 | |||
test ! -e /opt/mail/rspamd && test ! -e /opt/mail/log/rspamd || exit 1 | |||
mv /var/db/rspamd /opt/mail/rspamd | |||
mv /var/log/rspamd /opt/mail/log/rspamd | |||
ln -s /opt/mail/rspamd /var/db/rspamd | |||
ln -s /opt/mail/log/rspamd /var/log/rspamd | |||
chown -h root:wheel /var/db/rspamd /var/log/rspamd | |||
chown rspamd:rspamd /opt/mail/rspamd /opt/mail/log/rspamd | |||
chmod 0750 /opt/mail/rspamd /opt/mail/log/rspamd | |||
install -d -o rspamd -g rspamd -m 0700 /opt/mail/rspamd/tmp | |||
install -d -o redis -g redis -m 0700 /opt/mail/redis | |||
install -d -o redis -g redis -m 0750 /opt/mail/log/redis | |||
</pre> | |||
В /etc/rc.conf.d/redis добавить required_dirs="/opt/mail/redis /opt/mail/log/redis", в /etc/rc.conf.d/rspamd — required_dirs="/opt/mail/rspamd /opt/mail/log/rspamd". Не стирать другие действующие параметры этих файлов. | |||
=== 15.2. Настроить Redis и Rspamd === | |||
Сохранить следующий одноразовый скрипт как /root/setup-rspamd.py, root:wheel 0600. Он предназначен для новой установки, отказывается заменять существующие файлы секретов и не выводит их содержимое. Redis и контроллер слушают только loopback. Локальным процессам также нужен пароль контроллера: secure_ip очищен. Вызов rspamadm для хеша использует сгенерированный пароль как кратковременный аргумент процесса; не запускать этот шаг в среде с недоверенными локальными shell-пользователями. Пароль не записывается в историю команд. | |||
<pre> | |||
#!/usr/local/bin/python3.12 | |||
from pathlib import Path | |||
import grp, os, pwd, secrets, subprocess | |||
assert os.geteuid() == 0 | |||
os.umask(0o077) | |||
conf = Path('/usr/local/etc/rspamd') | |||
local = conf / 'local.d' | |||
local.mkdir(exist_ok=True) | |||
def write(path, value, group='wheel', mode=0o640): | |||
    path = Path(path) | |||
    path.write_text(value, encoding='utf-8') | |||
    os.chown(path, 0, grp.getgrnam(group).gr_gid) | |||
    os.chmod(path, mode) | |||
redis_secret = conf / 'redis.password' | |||
controller_secret = conf / 'controller.password' | |||
assert not redis_secret.exists() and not controller_secret.exists() | |||
redis_password = secrets.token_urlsafe(36) | |||
controller_password = secrets.token_urlsafe(36) | |||
write(redis_secret, redis_password + '\n', mode=0o600) | |||
write(controller_secret, controller_password + '\n', mode=0o600) | |||
hashed = subprocess.check_output( | |||
    ['/usr/local/bin/rspamadm', 'pw', '-q', '-p', controller_password], text=True | |||
).strip() | |||
assert hashed.startswith('$'), 'Unexpected password hash format' | |||
redis_conf = Path('/usr/local/etc/redis.conf') | |||
with redis_conf.open('a') as output: | |||
    output.write('\n# Dedicated Redis for the mail Rspamd service.\n' | |||
                 'bind 127.0.0.1\nprotected-mode yes\n' | |||
                 'dir /opt/mail/redis\n' | |||
                 'logfile /opt/mail/log/redis/redis.log\nloglevel notice\n' | |||
                 'appendonly yes\nappendfsync everysec\n' | |||
                 'maxmemory 256mb\nmaxmemory-policy noeviction\n' | |||
                 f'requirepass {redis_password}\n') | |||
os.chown(redis_conf, 0, grp.getgrnam('redis').gr_gid) | |||
os.chmod(redis_conf, 0o640) | |||
write(local / 'redis.conf', | |||
      f'servers = "127.0.0.1:6379";\npassword = "{redis_password}";\n', group='rspamd') | |||
write(local / 'worker-normal.inc', 'bind_socket = "127.0.0.1:11333";\n') | |||
write(local / 'worker-controller.inc', | |||
      'bind_socket = "127.0.0.1:11334";\nsecure_ip = [];\n' | |||
      f'password = "{hashed}";\nenable_password = "{hashed}";\n', group='rspamd') | |||
write(local / 'worker-proxy.inc', 'count = 0;\n') | |||
write(local / 'options.inc', | |||
      'check_all_filters = true;\ntempdir = "/opt/mail/rspamd/tmp";\n') | |||
write(local / 'logging.inc', 'level = "info";\n') | |||
write(local / 'classifier-bayes.conf', | |||
      'autolearn = false;\nper_user = false;\nmin_learns = 200;\n') | |||
for module in ('greylist', 'ratelimit', 'neural', 'force_actions', 'fuzzy_check'): | |||
    write(local / (module + '.conf'), 'enabled = false;\n') | |||
write(local / 'phishing.conf', | |||
      'phishtank_enabled = false;\nopenphish_enabled = true;\n' | |||
      'openphish_premium = false;\n' | |||
      'openphish_map = "https://raw.githubusercontent.com/openphish/public_feed/refs/heads/main/feed.txt";\n', | |||
      mode=0o644) | |||
print('Configuration written; secrets are stored in root-only files.') | |||
</pre> | |||
<pre> | |||
chmod 0600 /root/setup-rspamd.py | |||
python3.12 /root/setup-rspamd.py | |||
rspamadm configtest | |||
sysrc redis_enable=YES rspamd_enable=YES | |||
service redis start | |||
service rspamd start | |||
rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat | |||
sockstat -46l | egrep '6379|11333|11334' | |||
rm /root/setup-rspamd.py | |||
</pre> | |||
Проверить отсутствие Redis/Rspamd на внешних адресах. В local.d/options.inc не задан чужой DNS-сервер: используется системный резолвер; для DNSBL нужен разрешённый их политикой резолвер. Пароли не печатать через cat и не включать дамп полного конфигурационного файла в общие логи. При ошибке не подключать Exim, пока configtest, запуск и авторизованный stat не проходят. Значение min_learns = 200 означает, что для работы Bayes-оценок требуются примеры обоих классов: по 200 spam и ham; остальные проверки могут работать до накопления этих примеров. | |||
==== 15.2.1. OpenPhish вместо устаревшего PhishTank ==== | |||
Бесплатный OpenPhish Community Feed подключён через штатный модуль phishing. API-ключ не требуется. Rspamd загружает и автоматически обновляет список по HTTPS, затем проверяет ссылки из писем по локальной копии. Источник <code>phishtank.rspamd.com</code> отключён: эта DNS-зона больше не содержит данных, что отмечено в [https://raw.githubusercontent.com/rspamd/rspamd/4.2.3/ChangeLog журнале изменений Rspamd]. | |||
Скрипт новой установки выше уже записывает эти параметры. На существующей установке создать или дополнить <code>/usr/local/etc/rspamd/local.d/phishing.conf</code>, сохранив остальные действующие настройки: | |||
<pre> | |||
phishtank_enabled = false; | |||
openphish_enabled = true; | |||
openphish_premium = false; | |||
openphish_map = "https://raw.githubusercontent.com/openphish/public_feed/refs/heads/main/feed.txt"; | |||
</pre> | |||
Проверить конфигурацию и применить её без перезапуска Exim и Dovecot: | |||
<pre> | |||
chown root:wheel /usr/local/etc/rspamd/local.d/phishing.conf | |||
chmod 0644 /usr/local/etc/rspamd/local.d/phishing.conf | |||
rspamadm configtest | |||
# Продолжить только после syntax OK: | |||
service rspamd reload | |||
rspamadm configdump phishing | |||
service rspamd status | |||
</pre> | |||
В итоговой конфигурации должны быть <code>phishtank_enabled = false</code>, <code>openphish_enabled = true</code> и <code>openphish_premium = false</code>. Успешную загрузку списка подтверждает запись <code>parsed N elements from openphish feed</code> в журнале Rspamd, где <code>N > 0</code>. | |||
Точное совпадение ссылки с базой даёт символ <code>PHISHED_OPENPHISH</code>; на проверенной установке его вес — 7 баллов. Порог маркировки — 6 баллов, при этом итоговая оценка учитывает все сработавшие правила. Режим Exim из раздела 15.3 сохраняется: по антиспаму письмо принимается и помечается, а при включённом разделе 15.7 доставляется в Junk («Спам»). | |||
Проверено 29 октября 2026 года: Rspamd загрузил и разобрал 300 ссылок. Письмо со ссылкой из базы получило <code>PHISHED_OPENPHISH</code>, 7 баллов и действие <code>add header</code>; отрицательный контроль этого символа не получил. Тесты выполнены через локальный <code>/checkv2</code> без SMTP/IMAP-доставки и без обучения: счётчик <code>learned</code> остался 3 → 3. Число 300 относится к этой проверке; состав базы меняется. Конфигурации Exim и доставки Dovecot, пороги и настройка Bayes не изменялись. | |||
Чтобы отключить только OpenPhish, установить <code>openphish_enabled = false;</code>, выполнить <code>rspamadm configtest</code> и после успешной проверки — <code>service rspamd reload</code>. PhishTank оставить выключенным. | |||
Источник настройки: [https://docs.rspamd.com/modules/phishing/ Rspamd: модуль phishing и OpenPhish]. | |||
=== 15.3. Подключить Exim без антиспам-отказов === | |||
В главной части /usr/local/etc/exim/configure, до begin acl, добавить одну строку: | |||
<pre> | |||
spamd_address = 127.0.0.1 11333 variant=rspamd tmo=20s | |||
</pre> | |||
В acl_check_data после всех существующих проверок отправителя и после ClamAV, если он установлен, но перед последним accept, добавить следующий блок. Не заменять существующий malware-блок и не добавлять deny на основе $spam_action. | |||
<pre> | |||
  # Rspamd: annotate unauthenticated incoming mail; keep antivirus policy. | |||
  warn | |||
    remove_header = X-Spam-Score : X-Spam-Flag : X-Spam-Status : X-Spam-Report : X-Rspamd-Action | |||
  warn | |||
    !authenticated = * | |||
    spam = nobody:true/defer_ok | |||
    add_header = X-Spam-Score: $spam_score | |||
    add_header = X-Rspamd-Action: $spam_action | |||
    add_header = X-Spam-Flag: ${if inlist{$spam_action}{add header:rewrite subject:reject:quarantine:discard}{YES}{NO}} | |||
</pre> | |||
<pre> | |||
exim -bV | |||
service exim reload | |||
exim -bP spamd_address | |||
</pre> | |||
Проверка true возвращает результат оценки независимо от порога, а defer_ok позволяет продолжить SMTP при недоступном Rspamd. Очистка заголовков применяется ко всем SMTP-сообщениям, оценка — только к неаутентифицированным. Отправка через AUTH 465/587 не сканируется Rspamd и не может оставить собственную поддельную метку. Защита ClamAV действует независимо. Имена X-Spam-заголовков должны совпадать с необязательным правилом доставки ниже. | |||
=== 15.4. Создать защищённую очередь обратной связи === | |||
<pre> | |||
install -d -o root -g vmail -m 0750 /opt/mail/rspamd-feedback | |||
install -d -o vmail -g vmail -m 0700 /opt/mail/rspamd-feedback/pending | |||
install -d -o root -g wheel -m 0700 /opt/mail/rspamd-feedback/processed | |||
install -d -o root -g wheel -m 0755 /usr/local/libexec/rspamd-feedback | |||
install -d -o root -g wheel -m 0755 /usr/local/etc/dovecot/rspamd | |||
</pre> | |||
Ниже приведены три обязательных файла: сборщик, административная библиотека/CLI и фоновый обработчик. Worker импортирует административный модуль, тот импортирует сборщик: пропускать feedback-admin.py нельзя, даже если ручное обучение не используется. Все три файла принадлежат root:wheel, режим 0755, каталог не доступен на запись vmail. Сборщик принимает только фиксированные переходы INBOX/Junk, сообщение не более 50 MiB и ограниченную локальную очередь; к Rspamd он не обращается. | |||
==== /usr/local/libexec/rspamd-feedback/feedback.py ==== | |||
<pre> | |||
#!/usr/local/bin/python3.12 | |||
"""IMAPSieve collector: bounded local spool only; never contacts Rspamd. | |||
Usage: feedback.py [--queue DIR] spam|ham USER SOURCE DESTINATION | |||
The queue must already exist and be owned by the IMAP mail UID, mode 0700. | |||
Programs/configuration must be root-owned and not writable by that UID. | |||
""" | |||
from __future__ import annotations | |||
import argparse | |||
import hashlib | |||
import json | |||
import os | |||
from pathlib import Path | |||
import secrets | |||
import shutil | |||
import stat | |||
import sys | |||
import time | |||
from contextlib import contextmanager | |||
from datetime import datetime, timezone | |||
MAX_MESSAGE = 50 * 1024 * 1024 | |||
DEFAULT_QUEUE = "/opt/mail/rspamd-feedback/pending" | |||
MAX_PENDING_BYTES = 512 * 1024 * 1024 | |||
MAX_PENDING_EVENTS = 1024 | |||
MIN_FREE_BYTES = 1024 * 1024 * 1024 | |||
class FeedbackError(Exception): | |||
    pass | |||
def utc_now(): | |||
    return datetime.now(timezone.utc).isoformat(timespec="seconds") | |||
def fsync_directory(path): | |||
    if os.name == "posix": | |||
        fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) | |||
        try: | |||
            os.fsync(fd) | |||
        finally: | |||
            os.close(fd) | |||
def validate_event(label, user, source, destination): | |||
    expected = {"spam": ("INBOX", "Junk"), "ham": ("Junk", "INBOX")} | |||
    if label not in expected or (source, destination) != expected[label]: | |||
        raise FeedbackError("event is outside the configured INBOX/Junk transitions") | |||
    if not user or len(user) > 320 or any(ord(c) < 32 or ord(c) == 127 for c in user): | |||
        raise FeedbackError("invalid IMAP user identity") | |||
@contextmanager | |||
def collection_lock(queue): | |||
    """At most two seconds waiting; IMAP must not wait indefinitely on a lock.""" | |||
    fd = os.open(queue / ".collector.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) | |||
    try: | |||
        if os.name == "posix": | |||
            import fcntl | |||
            until = time.monotonic() + 2 | |||
            while True: | |||
                try: | |||
                    fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) | |||
                    break | |||
                except BlockingIOError: | |||
                    if time.monotonic() >= until: | |||
                        raise FeedbackError("queue lock is busy") | |||
                    time.sleep(0.02) | |||
        yield | |||
    finally: | |||
        os.close(fd) | |||
def pending_usage(queue, max_events=MAX_PENDING_EVENTS): | |||
    count = total = 0 | |||
    with os.scandir(queue) as entries: | |||
        for entry in entries: | |||
            if entry.name == ".collector.lock": | |||
                continue | |||
            count += 1 | |||
            if count >= max_events: | |||
                raise FeedbackError("pending event limit reached") | |||
            if not entry.is_dir(follow_symlinks=False): | |||
                raise FeedbackError("unexpected item in pending queue") | |||
            flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) | |||
            try: | |||
                directory = os.open(entry.path, flags) if os.name == "posix" else None | |||
            except FileNotFoundError: | |||
                continue | |||
            try: | |||
                for name in ("message.eml", "metadata.json"): | |||
                    try: | |||
                        info = (os.stat(name, dir_fd=directory, follow_symlinks=False) | |||
                                if directory is not None else (Path(entry.path) / name).lstat()) | |||
                    except FileNotFoundError: | |||
                        continue | |||
                    if not stat.S_ISREG(info.st_mode): | |||
                        raise FeedbackError("unexpected file type in pending event") | |||
                    total += info.st_size | |||
            finally: | |||
                if directory is not None: | |||
                    os.close(directory) | |||
    return total | |||
def collect(stream, queue, label, user, source, destination, *, max_bytes=MAX_MESSAGE, | |||
            max_pending_bytes=MAX_PENDING_BYTES, max_events=MAX_PENDING_EVENTS, | |||
            min_free_bytes=MIN_FREE_BYTES): | |||
    validate_event(label, user, source, destination) | |||
    queue = Path(queue) | |||
    info = queue.lstat() | |||
    if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): | |||
        raise FeedbackError("queue must be a real directory") | |||
    if os.name == "posix" and (info.st_uid != os.geteuid() or info.st_mode & 0o077): | |||
        raise FeedbackError("queue must belong to this UID and have mode 0700") | |||
    with collection_lock(queue): | |||
        current_bytes = pending_usage(queue, max_events) | |||
        # Reserve metadata and preserve 1 GiB even for a maximum-sized message. | |||
        if current_bytes + 16384 >= max_pending_bytes: | |||
            raise FeedbackError("pending byte limit reached") | |||
        if shutil.disk_usage(queue).free < min_free_bytes + max_bytes + 16384: | |||
            raise FeedbackError("insufficient free disk space") | |||
        return _write_event(stream, queue, label, user, source, destination, | |||
                            max_bytes, min(max_bytes, max_pending_bytes - current_bytes - 16384)) | |||
def _write_event(stream, queue, label, user, source, destination, max_bytes, available_bytes): | |||
    event_id = secrets.token_hex(16) | |||
    temporary = queue / (".tmp-" + event_id) | |||
    final = queue / event_id | |||
    temporary.mkdir(mode=0o700) | |||
    total = 0 | |||
    digest = hashlib.sha256() | |||
    try: | |||
        fd = os.open(temporary / "message.eml", os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600) | |||
        with os.fdopen(fd, "wb") as output: | |||
            while True: | |||
                block = stream.read(min(65536, max_bytes + 1 - total)) | |||
                if not block: | |||
                    break | |||
                total += len(block) | |||
                if total > max_bytes: | |||
                    raise FeedbackError("message exceeds the 50 MiB collection limit") | |||
                if total > available_bytes: | |||
                    raise FeedbackError("pending byte limit reached") | |||
                digest.update(block) | |||
                output.write(block) | |||
            if total == 0: | |||
                raise FeedbackError("empty message") | |||
            output.flush() | |||
            os.fsync(output.fileno()) | |||
        metadata = { | |||
            "schema": 1, "id": event_id, "created_utc": utc_now(), | |||
            "created_ns": time.time_ns(), | |||
            "label": label, "user": user, "source": source, | |||
            "destination": destination, "bytes": total, | |||
            "sha256": digest.hexdigest(), | |||
        } | |||
        fd = os.open(temporary / "metadata.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600) | |||
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as output: | |||
            json.dump(metadata, output, ensure_ascii=True, sort_keys=True) | |||
            output.write("\n") | |||
            output.flush() | |||
            os.fsync(output.fileno()) | |||
        fsync_directory(temporary) | |||
        os.rename(temporary, final) | |||
        fsync_directory(queue) | |||
        return metadata | |||
    except Exception: | |||
        # Remove only our two known partial files, never recurse into a directory. | |||
        for name in ("message.eml", "metadata.json"): | |||
            try: | |||
                (temporary / name).unlink() | |||
            except FileNotFoundError: | |||
                pass | |||
        try: | |||
            temporary.rmdir() | |||
        except FileNotFoundError: | |||
            pass | |||
        raise | |||
def main(argv=None): | |||
    parser = argparse.ArgumentParser(description=__doc__) | |||
    parser.add_argument("--queue", default=DEFAULT_QUEUE) | |||
    parser.add_argument("label", choices=("spam", "ham")) | |||
    parser.add_argument("user") | |||
    parser.add_argument("source") | |||
    parser.add_argument("destination") | |||
    args = parser.parse_args(argv) | |||
    os.umask(0o077) | |||
    try: | |||
        collect(sys.stdin.buffer, args.queue, args.label, args.user, args.source, args.destination) | |||
        return 0 | |||
    except (FeedbackError, OSError) as exc: | |||
        # No message data, addresses, paths supplied by users, or tracebacks in logs. | |||
        detail = str(exc) if isinstance(exc, FeedbackError) else "local IO error errno=" + str(exc.errno) | |||
        message = "rspamd-feedback: collection failed: " + detail + "; no training attempted" | |||
        try: | |||
            import syslog | |||
            syslog.openlog("rspamd-feedback", syslog.LOG_PID, syslog.LOG_MAIL) | |||
            syslog.syslog(syslog.LOG_ERR, message) | |||
        except ImportError: | |||
            pass | |||
        print(message, file=sys.stderr) | |||
        return 1 | |||
if __name__ == "__main__": | |||
    raise SystemExit(main()) | |||
</pre> | |||
==== /usr/local/libexec/rspamd-feedback/feedback-admin.py ==== | |||
Это общая проверка событий, защищённые снимки, журнал результатов и диагностические команды. Ручная learn-команда сохранена для исключительных случаев; обычные IMAP-события обрабатывает worker из следующего файла. | |||
<pre> | |||
#!/usr/local/bin/python3.12 | |||
"""Root-only review and explicit training of IMAPSieve feedback. | |||
No command trains automatically. list/show are local reads. learn ID spam|ham | |||
is the only command that invokes rspamc. reject ID archives without training. | |||
Queue root and processed must be root-owned, non-writable by mail users; | |||
pending is separately owned by vmail, mode 0700. Archived originals remain in | |||
processed/ID/source; only root-owned snapshots are used for training or review. | |||
""" | |||
from __future__ import annotations | |||
import argparse | |||
from contextlib import contextmanager | |||
from email import policy | |||
from email.parser import BytesHeaderParser | |||
import hashlib | |||
import importlib.util | |||
import json | |||
import os | |||
from pathlib import Path | |||
import re | |||
import shutil | |||
import stat | |||
import subprocess | |||
import sys | |||
spec = importlib.util.spec_from_file_location("feedback_collector", Path(__file__).with_name("feedback.py")) | |||
collector = importlib.util.module_from_spec(spec) | |||
spec.loader.exec_module(collector) | |||
MAX_MESSAGE = collector.MAX_MESSAGE | |||
FeedbackError = collector.FeedbackError | |||
ID_RE = re.compile(r"[0-9a-f]{32}\Z") | |||
DEFAULT_ROOT = "/opt/mail/rspamd-feedback" | |||
DEFAULT_PASSWORD_FILE = "/usr/local/etc/rspamd/controller.password" | |||
HEADERS = ("From", "To", "Date", "Subject", "Message-ID", "X-Spam-Status", "X-Spam-Score", "X-Rspamd-Action") | |||
def emit(value): | |||
    print(json.dumps(value, ensure_ascii=True, sort_keys=True)) | |||
def require_root(): | |||
    if not hasattr(os, "geteuid") or os.geteuid() != 0: | |||
        raise FeedbackError("this administrative command requires root") | |||
def check_id(event_id): | |||
    if not ID_RE.fullmatch(event_id): | |||
        raise FeedbackError("ID must be exactly 32 lowercase hexadecimal characters") | |||
    return event_id | |||
class Directory: | |||
    """Nofollow directory descriptor on FreeBSD; portable fallback for local tests.""" | |||
    def __init__(self, path): | |||
        self.path = Path(path) | |||
        info = self.path.lstat() | |||
        if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): | |||
            raise FeedbackError("expected a real directory") | |||
        self.fd = None | |||
        if os.name == "posix": | |||
            self.fd = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) | |||
    def __enter__(self): | |||
        return self | |||
    def __exit__(self, *args): | |||
        if self.fd is not None: | |||
            os.close(self.fd) | |||
    def read(self, name, limit): | |||
        if "/" in name or "\\" in name or name in (".", ".."): | |||
            raise FeedbackError("invalid internal filename") | |||
        if self.fd is not None: | |||
            fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=self.fd) | |||
        else: | |||
            path = self.path / name | |||
            if path.is_symlink(): | |||
                raise FeedbackError("symlink is not an event file") | |||
            fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0)) | |||
        with os.fdopen(fd, "rb") as source: | |||
            info = os.fstat(source.fileno()) | |||
            if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > limit: | |||
                raise FeedbackError("event file is not a bounded, single-link regular file") | |||
            result = source.read(limit + 1) | |||
            if len(result) > limit: | |||
                raise FeedbackError("event file exceeded the size limit") | |||
            return result | |||
def read_event(directory, event_id): | |||
    check_id(event_id) | |||
    # On POSIX open the event relative to the already opened parent descriptor. | |||
    # The parent path is deployment-owned, and a symlink event is rejected. | |||
    with Directory(Path(directory) / event_id) as entry: | |||
        metadata = json.loads(entry.read("metadata.json", 16384)) | |||
        message = entry.read("message.eml", MAX_MESSAGE) | |||
    if not isinstance(metadata, dict) or not isinstance(metadata.get("user"), str): | |||
        raise FeedbackError("invalid event metadata") | |||
    collector.validate_event(metadata.get("label"), metadata.get("user", ""), | |||
                             metadata.get("source"), metadata.get("destination")) | |||
    if metadata.get("id") != event_id or metadata.get("schema") != 1: | |||
        raise FeedbackError("invalid event metadata") | |||
    if metadata.get("bytes") != len(message) or metadata.get("sha256") != hashlib.sha256(message).hexdigest(): | |||
        raise FeedbackError("event content does not match its metadata") | |||
    return metadata, message | |||
def write_private(path, value, *, json_value=False): | |||
    flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) | |||
    fd = os.open(path, flags, 0o600) | |||
    with os.fdopen(fd, "wb") as output: | |||
        if json_value: | |||
            value = (json.dumps(value, ensure_ascii=True, sort_keys=True) + "\n").encode() | |||
        output.write(value) | |||
        output.flush() | |||
        os.fsync(output.fileno()) | |||
def update_receipt(directory, receipt): | |||
    temporary = directory / ".admin-new.json" | |||
    write_private(temporary, receipt, json_value=True) | |||
    os.replace(temporary, directory / "admin.json") | |||
    collector.fsync_directory(directory) | |||
def check_root_directory(path): | |||
    with Directory(path): | |||
        info = Path(path).stat() | |||
        if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o022): | |||
            raise FeedbackError("administrative directories must be root-owned and not group/other writable") | |||
@contextmanager | |||
def admin_lock(root): | |||
    check_root_directory(root) | |||
    processed = root / "processed" | |||
    if not processed.exists(): | |||
        processed.mkdir(mode=0o700) | |||
    check_root_directory(processed) | |||
    fd = os.open(root / ".admin.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) | |||
    try: | |||
        if os.name == "posix": | |||
            import fcntl | |||
            fcntl.flock(fd, fcntl.LOCK_EX) | |||
        yield | |||
    finally: | |||
        os.close(fd) | |||
def receipt_for(directory): | |||
    with Directory(directory) as entry: | |||
        return json.loads(entry.read("admin.json", 16384)) | |||
def find_prior(root, sha256): | |||
    processed = root / "processed" | |||
    if not processed.exists(): | |||
        return None | |||
    for entry in processed.iterdir(): | |||
        if not ID_RE.fullmatch(entry.name): | |||
            continue | |||
        try: | |||
            receipt = receipt_for(entry) | |||
        except (OSError, ValueError, FeedbackError): | |||
            # A damaged journal must never silently allow repeated training. | |||
            raise FeedbackError("processed journal is incomplete; inspect it before more training") | |||
        if receipt.get("sha256") == sha256 and receipt.get("status") in ("preparing", "learning", "learned", "learn_uncertain"): | |||
            return receipt | |||
    return None | |||
def prepare_archive(root, event_id, metadata, message, status, label=None, reason=None): | |||
    if shutil.disk_usage(root).free < collector.MIN_FREE_BYTES + len(message) + 32768: | |||
        raise FeedbackError("insufficient free disk space to preserve the reviewed snapshot") | |||
    archive = root / "processed" / event_id | |||
    archive.mkdir(mode=0o700) | |||
    receipt = {"schema": 1, "id": event_id, "sha256": metadata["sha256"], | |||
               "reviewed_utc": collector.utc_now(), "status": "preparing", | |||
               "selected_class": label, "reason": reason} | |||
    write_private(archive / "admin.json", receipt, json_value=True) | |||
    write_private(archive / "message.eml", message) | |||
    write_private(archive / "metadata.json", json.dumps(metadata, ensure_ascii=True).encode()) | |||
    # Keep the complete original event. Never traverse or execute files in source/. | |||
    os.rename(root / "pending" / event_id, archive / "source") | |||
    receipt["status"] = status | |||
    update_receipt(archive, receipt) | |||
    collector.fsync_directory(root / "pending") | |||
    collector.fsync_directory(root / "processed") | |||
    return archive, receipt | |||
def run_learning(message, label, *, executable, controller, password_file=None, timeout=30): | |||
    executable = Path(executable) | |||
    if not executable.is_absolute(): | |||
        raise FeedbackError("rspamc executable must be an absolute path") | |||
    command = [str(executable), "-h", controller] | |||
    if password_file: | |||
        secret = Path(password_file) | |||
        info = secret.lstat() | |||
        if not secret.is_absolute() or not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): | |||
            raise FeedbackError("password file must be an absolute regular file") | |||
        if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o077): | |||
            raise FeedbackError("password file must be root-owned, mode 0600 or stricter") | |||
        # rspamc reads the file itself; the secret never appears in argv or logs. | |||
        command.extend(["-P", str(secret)]) | |||
    command.append("learn_" + label) | |||
    # No -u/-d: one shared Bayes classifier. No shell and no automatic retry. | |||
    return subprocess.run(command, input=message, stdout=subprocess.DEVNULL, | |||
                          stderr=subprocess.DEVNULL, timeout=timeout, check=False).returncode | |||
def learn_event(root, event_id, label, *, executable, controller, password_file=None, timeout=30): | |||
    check_id(event_id) | |||
    if label not in ("spam", "ham"): | |||
        raise FeedbackError("invalid training class") | |||
    with admin_lock(root): | |||
        metadata, message = read_event(root / "pending", event_id) | |||
        prior = find_prior(root, metadata["sha256"]) | |||
        if prior: | |||
            raise FeedbackError("this content has a prior learned or uncertain event; inspect it and reject the duplicate") | |||
        archive, receipt = prepare_archive(root, event_id, metadata, message, "learning", label) | |||
        try: | |||
            rc = run_learning(message, label, executable=executable, controller=controller, | |||
                              password_file=password_file, timeout=timeout) | |||
        except subprocess.TimeoutExpired: | |||
            receipt["status"] = "learn_uncertain" | |||
            receipt["error"] = "client timeout; server may have learned; do not automatically retry" | |||
        except (OSError, FeedbackError): | |||
            receipt["status"] = "learn_failed" | |||
            receipt["error"] = "client could not start or local configuration was invalid" | |||
        else: | |||
            receipt["returncode"] = rc | |||
            receipt["status"] = "learned" if rc == 0 else "learn_uncertain" | |||
            if rc: | |||
                receipt["error"] = "client returned failure; inspect server before any retry" | |||
        receipt["finished_utc"] = collector.utc_now() | |||
        update_receipt(archive, receipt) | |||
        return receipt | |||
def reject_event(root, event_id, reason="review rejected"): | |||
    check_id(event_id) | |||
    if len(reason) > 500: | |||
        raise FeedbackError("reason is too long") | |||
    with admin_lock(root): | |||
        metadata, message = read_event(root / "pending", event_id) | |||
        _, receipt = prepare_archive(root, event_id, metadata, message, "rejected", reason=reason) | |||
        return receipt | |||
def list_events(root, state, limit): | |||
    count = 0 | |||
    for name in (("pending", "processed") if state == "all" else (state,)): | |||
        directory = root / name | |||
        if not directory.exists(): | |||
            continue | |||
        with Directory(directory): | |||
            for entry in directory.iterdir(): | |||
                if not ID_RE.fullmatch(entry.name): | |||
                    continue | |||
                if count >= limit: | |||
                    emit({"notice": "limit reached", "limit": limit}) | |||
                    return | |||
                try: | |||
                    with Directory(entry) as item: | |||
                        metadata = json.loads(item.read("metadata.json", 16384)) | |||
                    row = {key: metadata.get(key) for key in ("id", "created_utc", "label", "user", "source", "destination", "bytes")} | |||
                    row["state"] = name | |||
                    if name == "processed": | |||
                        row["review"] = receipt_for(entry) | |||
                    emit(row) | |||
                except (OSError, ValueError, FeedbackError): | |||
                    emit({"id": entry.name, "state": name, "error": "invalid event; manual inspection required"}) | |||
                count += 1 | |||
def show_event(root, event_id, state, path_only): | |||
    metadata, message = read_event(root / state, check_id(event_id)) | |||
    if path_only: | |||
        emit({"id": event_id, "message_path": str(root / state / event_id / "message.eml")}) | |||
        return | |||
    headers = BytesHeaderParser(policy=policy.default).parsebytes(message[:min(len(message), 262144)]) | |||
    emit({"metadata": metadata, "headers": {name: [str(v)[:2000] for v in headers.get_all(name, [])[:10]] for name in HEADERS}}) | |||
    if state == "processed": | |||
        emit({"review": receipt_for(root / state / event_id)}) | |||
def warn_processed_storage(root): | |||
    processed = root / "processed" | |||
    if not processed.exists(): | |||
        return | |||
    total = count = 0 | |||
    for event in processed.iterdir(): | |||
        if not ID_RE.fullmatch(event.name) or event.is_symlink() or not event.is_dir(): | |||
            continue | |||
        count += 1 | |||
        for prefix in (event, event / "source"): | |||
            if prefix.is_symlink(): | |||
                continue | |||
            for name in ("message.eml", "metadata.json", "admin.json"): | |||
                try: | |||
                    info = (prefix / name).lstat() | |||
                    if stat.S_ISREG(info.st_mode): | |||
                        total += info.st_size | |||
                except FileNotFoundError: | |||
                    pass | |||
    if total >= collector.MAX_PENDING_BYTES or count >= collector.MAX_PENDING_EVENTS: | |||
        print("rspamd-feedback-admin: WARNING processed archive reached 512 MiB or 1024 events; review retention manually; nothing deleted", file=sys.stderr) | |||
def main(argv=None): | |||
    parser = argparse.ArgumentParser(description=__doc__) | |||
    parser.add_argument("--queue-root", default=DEFAULT_ROOT) | |||
    sub = parser.add_subparsers(dest="command", required=True) | |||
    listing = sub.add_parser("list", help="JSON lines, metadata only") | |||
    listing.add_argument("--state", choices=("pending", "processed", "all"), default="pending") | |||
    listing.add_argument("--limit", type=int, default=100) | |||
    showing = sub.add_parser("show", help="selected headers only; never dumps the body") | |||
    showing.add_argument("id") | |||
    showing.add_argument("--state", choices=("pending", "processed"), default="pending") | |||
    showing.add_argument("--path", action="store_true") | |||
    learning = sub.add_parser("learn", help="explicitly train one reviewed event, then archive it") | |||
    learning.add_argument("id") | |||
    learning.add_argument("label", choices=("spam", "ham")) | |||
    learning.add_argument("--rspamc-bin", default="/usr/local/bin/rspamc") | |||
    learning.add_argument("--controller", default="127.0.0.1:11334") | |||
    learning.add_argument("--password-file", default=DEFAULT_PASSWORD_FILE, | |||
                          help="root-only plaintext secret file; use --no-password only for an intentionally trusted local controller") | |||
    learning.add_argument("--no-password", action="store_true") | |||
    learning.add_argument("--timeout", type=float, default=30) | |||
    rejecting = sub.add_parser("reject", help="archive one event without training or deleting it") | |||
    rejecting.add_argument("id") | |||
    rejecting.add_argument("--reason", default="review rejected") | |||
    args = parser.parse_args(argv) | |||
    os.umask(0o077) | |||
    try: | |||
        require_root() | |||
        root = Path(args.queue_root) | |||
        if not root.is_absolute(): | |||
            raise FeedbackError("queue root must be absolute") | |||
        check_root_directory(root) | |||
        warn_processed_storage(root) | |||
        if args.command == "list": | |||
            if not 1 <= args.limit <= 10000: | |||
                raise FeedbackError("limit must be between 1 and 10000") | |||
            list_events(root, args.state, args.limit) | |||
        elif args.command == "show": | |||
            show_event(root, args.id, args.state, args.path) | |||
        elif args.command == "reject": | |||
            emit(reject_event(root, args.id, args.reason)) | |||
        else: | |||
            if not 0 < args.timeout <= 300: | |||
                raise FeedbackError("timeout must be between 0 and 300 seconds") | |||
            result = learn_event(root, args.id, args.label, executable=args.rspamc_bin, | |||
                                 controller=args.controller, password_file=None if args.no_password else args.password_file, | |||
                                 timeout=args.timeout) | |||
            emit(result) | |||
            return 0 if result["status"] == "learned" else 2 | |||
        return 0 | |||
    except (FeedbackError, OSError, ValueError, TypeError): | |||
        print("rspamd-feedback-admin: operation refused or failed; inspect configuration/event locally", file=sys.stderr) | |||
        return 1 | |||
if __name__ == "__main__": | |||
    raise SystemExit(main()) | |||
</pre> | |||
==== /usr/local/libexec/rspamd-feedback/feedback-worker.py ==== | |||
Служба обрабатывает события по точному времени, сохраняет намерение до запроса обучения и исключает повтор одного и того же класса для того же содержимого. Обратный переход передаёт противоположный класс. Недоступный до запроса контроллер оставляет событие pending. Неопределённый результат уже отправленного запроса автоматически не повторяется. | |||
<pre> | |||
#!/usr/local/bin/python3.12 | |||
"""Root-only background learning from the bounded IMAPSieve feedback queue. | |||
The collector remains a local spool writer. This process owns controller | |||
credentials, learns only validated INBOX/Junk events, preserves every original, | |||
and never retries a training operation with an uncertain result. Run foreground | |||
under FreeBSD daemon(8), or use --once for one bounded batch. | |||
""" | |||
from __future__ import annotations | |||
import argparse | |||
from datetime import datetime | |||
import importlib.util | |||
import json | |||
import os | |||
from pathlib import Path | |||
import re | |||
import signal | |||
import stat | |||
import subprocess | |||
import sys | |||
import threading | |||
import time | |||
_spec = importlib.util.spec_from_file_location("feedback_worker_admin", Path(__file__).with_name("feedback-admin.py")) | |||
admin = importlib.util.module_from_spec(_spec) | |||
_spec.loader.exec_module(admin) | |||
FeedbackError = admin.FeedbackError | |||
SHA_RE = re.compile(r"[0-9a-f]{64}\Z") | |||
MAX_JOURNAL_EVENTS = 20000 | |||
UNCERTAIN = {"preparing", "learning", "learn_uncertain", "quarantined_uncertain", "quarantined_ordering"} | |||
def log_event(kind, **fields): | |||
    # Callers pass only fixed diagnostics, numeric counts, event IDs and classes. | |||
    print(json.dumps({"event": kind, **fields}, ensure_ascii=True, sort_keys=True), flush=True) | |||
def _timestamp_ns(value): | |||
    if not isinstance(value, str): | |||
        raise ValueError("missing timestamp") | |||
    parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) | |||
    if parsed.tzinfo is None: | |||
        raise ValueError("timestamp must have timezone") | |||
    return int(parsed.timestamp() * 1_000_000_000) | |||
def order_events(root): | |||
    """Read bounded metadata, ordered by collector created_ns, then event ID. | |||
    Legacy second-resolution timestamps are permitted only when the same | |||
    content has no contradictory labels at an identical timestamp. Such a tie | |||
    cannot be resolved from random event IDs and is quarantined without learning. | |||
    """ | |||
    pending = Path(root) / "pending" | |||
    rows = [] | |||
    count = 0 | |||
    with admin.Directory(pending): | |||
        with os.scandir(pending) as entries: | |||
            for entry in entries: | |||
                if not admin.ID_RE.fullmatch(entry.name): | |||
                    continue | |||
                count += 1 | |||
                if count > admin.collector.MAX_PENDING_EVENTS: | |||
                    raise FeedbackError("pending queue scan bound exceeded") | |||
                try: | |||
                    with admin.Directory(pending / entry.name) as directory: | |||
                        metadata = json.loads(directory.read("metadata.json", 16384)) | |||
                    if not isinstance(metadata, dict): | |||
                        raise ValueError("invalid metadata") | |||
                    metadata = dict(metadata) | |||
                    if metadata.get("id") != entry.name or metadata.get("schema") != 1: | |||
                        raise ValueError("invalid event identity") | |||
                    if not isinstance(metadata.get("sha256"), str) or not SHA_RE.fullmatch(metadata["sha256"]): | |||
                        raise ValueError("invalid digest") | |||
                    admin.collector.validate_event(metadata.get("label"), metadata.get("user", ""), | |||
                                                   metadata.get("source"), metadata.get("destination")) | |||
                    timestamp = metadata.get("created_ns") | |||
                    if isinstance(timestamp, int) and not isinstance(timestamp, bool) and timestamp > 0: | |||
                        metadata["_order_ns"] = timestamp | |||
                    elif "created_ns" not in metadata: | |||
                        metadata["_order_ns"] = _timestamp_ns(metadata.get("created_utc")) | |||
                    else: | |||
                        metadata["_order_ns"] = 0 | |||
                        metadata["_ordering_invalid"] = True | |||
                    rows.append(metadata) | |||
                except FileNotFoundError: | |||
                    # A manual administrator may have archived this candidate. | |||
                    continue | |||
                except (OSError, ValueError, TypeError, FeedbackError): | |||
                    rows.append({"id": entry.name, "_invalid": True, "_order_ns": 0}) | |||
    tied = {} | |||
    for row in rows: | |||
        if not row.get("_invalid"): | |||
            tied.setdefault((row["_order_ns"], row["sha256"]), set()).add(row["label"]) | |||
    for row in rows: | |||
        if not row.get("_invalid") and len(tied[(row["_order_ns"], row["sha256"])]) > 1: | |||
            row["_ordering_invalid"] = True | |||
    return sorted(rows, key=lambda row: (row["_order_ns"], row["id"])) | |||
def _journal(root): | |||
    """Read root-owned receipts; successful classes use durable worker sequence.""" | |||
    processed = Path(root) / "processed" | |||
    latest, blocked = {}, set() | |||
    max_sequence = used_bytes = count = 0 | |||
    if not processed.exists(): | |||
        return latest, blocked, max_sequence, used_bytes, count | |||
    admin.check_root_directory(processed) | |||
    with os.scandir(processed) as entries: | |||
        for entry in entries: | |||
            if not admin.ID_RE.fullmatch(entry.name): | |||
                continue | |||
            count += 1 | |||
            if count > MAX_JOURNAL_EVENTS: | |||
                raise FeedbackError("processed journal scan bound exceeded") | |||
            directory = processed / entry.name | |||
            receipt = admin.receipt_for(directory) | |||
            if not isinstance(receipt, dict) or receipt.get("id") != entry.name: | |||
                raise FeedbackError("invalid processed receipt") | |||
            digest = receipt.get("sha256") | |||
            if not isinstance(digest, str) or not SHA_RE.fullmatch(digest): | |||
                raise FeedbackError("invalid processed digest") | |||
            sequence = receipt.get("worker_sequence", 0) | |||
            if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0: | |||
                raise FeedbackError("invalid processed sequence") | |||
            max_sequence = max(max_sequence, sequence) | |||
            status = receipt.get("status") | |||
            if status in UNCERTAIN: | |||
                blocked.add(digest) | |||
            if status == "learned": | |||
                label = receipt.get("selected_class") | |||
                if label not in ("spam", "ham"): | |||
                    raise FeedbackError("invalid learned class") | |||
                # Existing manual receipts predate automatic processing; the old | |||
                # admin refuses further training of a SHA once it has a receipt. | |||
                fallback = _timestamp_ns(receipt.get("finished_utc", receipt.get("reviewed_utc"))) | |||
                position = (bool(sequence), sequence if sequence else fallback, entry.name) | |||
                if digest not in latest or position > latest[digest][0]: | |||
                    latest[digest] = (position, label) | |||
            # Count snapshots and preserved originals, never recursively walk | |||
            # arbitrary files in the mail-user-owned source/ directory. | |||
            for part in (directory, directory / "source"): | |||
                try: | |||
                    with admin.Directory(part) as safe: | |||
                        for name in ("message.eml", "metadata.json", "admin.json"): | |||
                            try: | |||
                                if safe.fd is not None: | |||
                                    info = os.stat(name, dir_fd=safe.fd, follow_symlinks=False) | |||
                                else: | |||
                                    info = (part / name).lstat() | |||
                                if stat.S_ISREG(info.st_mode): | |||
                                    used_bytes += info.st_size | |||
                            except FileNotFoundError: | |||
                                continue | |||
                except FileNotFoundError: | |||
                    continue | |||
    return latest, blocked, max_sequence, used_bytes, count | |||
def _check_trusted_parent_chain(path): | |||
    if os.name != "posix": | |||
        return | |||
    for parent in Path(path).parents: | |||
        link_info = parent.lstat() | |||
        info = parent.stat() | |||
        if (link_info.st_uid != 0 or not stat.S_ISDIR(info.st_mode) | |||
                or info.st_uid != 0 or info.st_mode & 0o022): | |||
            raise FeedbackError("executable parent directories must be controlled by root") | |||
def resolve_executable(executable): | |||
    """Allow FreeBSD package symlinks, but pin both operations to their target.""" | |||
    source = Path(executable) | |||
    if not source.is_absolute(): | |||
        raise FeedbackError("rspamc executable must be absolute") | |||
    source_info = source.lstat() | |||
    if os.name == "posix" and source_info.st_uid != 0: | |||
        raise FeedbackError("rspamc entry must be owned by root") | |||
    _check_trusted_parent_chain(source) | |||
    target = source.resolve(strict=True) | |||
    info = target.lstat() | |||
    if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): | |||
        raise FeedbackError("rspamc target must be a regular file") | |||
    if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o022): | |||
        raise FeedbackError("rspamc target must be root-owned and not writable by mail users") | |||
    _check_trusted_parent_chain(target) | |||
    return str(target) | |||
def controller_ready(executable, controller, password_file, timeout): | |||
    """Authenticated read-only controller probe; no candidate is archived yet.""" | |||
    command = [resolve_executable(executable), "-h", controller] | |||
    if password_file: | |||
        secret = Path(password_file) | |||
        info = secret.lstat() | |||
        if not secret.is_absolute() or not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): | |||
            raise FeedbackError("unsafe controller password file") | |||
        if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o077): | |||
            raise FeedbackError("controller password file must be root-owned mode 0600 or stricter") | |||
        command.extend(["-P", str(secret)]) | |||
    command.append("stat") | |||
    try: | |||
        return subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, | |||
                              stderr=subprocess.DEVNULL, check=False, timeout=min(timeout, 5)).returncode == 0 | |||
    except (subprocess.TimeoutExpired, OSError): | |||
        return False | |||
def process_pending(root, *, executable="/usr/local/bin/rspamc", controller="127.0.0.1:11334", | |||
                    password_file=admin.DEFAULT_PASSWORD_FILE, timeout=30, batch_size=20, | |||
                    preflight=None, learner=None, stop_event=None): | |||
    """Process at most batch_size candidates. Inject probes/learner for unit tests. | |||
    One admin lock serializes manual and background operations. A crash after a | |||
    durable learning intent leaves status=learning, which blocks this SHA on the | |||
    next pass; subsequent candidates are preserved as quarantined_uncertain. | |||
    """ | |||
    root = Path(root) | |||
    preflight = controller_ready if preflight is None else preflight | |||
    if learner is None: | |||
        # FreeBSD pkg installs rspamc as a root-owned versioned symlink. Resolve | |||
        # it once so preflight and training invoke the same trusted executable. | |||
        executable = resolve_executable(executable) | |||
        learner = admin.run_learning | |||
    summary = {"learned": 0, "duplicates": 0, "quarantined": 0, "invalid": 0, | |||
               "pending": 0, "controller_unavailable": False, "storage_blocked": False, | |||
               "archive_warning": False} | |||
    with admin.admin_lock(root): | |||
        events = order_events(root) | |||
        summary["pending"] = len(events) | |||
        if not events: | |||
            return summary | |||
        latest, blocked, sequence, used_bytes, archive_count = _journal(root) | |||
        summary["archive_warning"] = archive_count >= admin.collector.MAX_PENDING_EVENTS | |||
        if used_bytes >= admin.collector.MAX_PENDING_BYTES: | |||
            summary["storage_blocked"] = True | |||
            return summary | |||
        if not preflight(executable, controller, password_file, timeout): | |||
            summary["controller_unavailable"] = True | |||
            return summary | |||
        handled = 0 | |||
        for candidate in events: | |||
            if handled >= batch_size or (stop_event is not None and stop_event.is_set()): | |||
                break | |||
            if candidate.get("_invalid"): | |||
                summary["invalid"] += 1 | |||
                continue | |||
            event_id = candidate["id"] | |||
            if (root / "processed" / event_id).exists(): | |||
                # Crash before prepare_archive moved pending/ID: its root-owned | |||
                # intent is authoritative. Preserve the pending original for | |||
                # manual recovery and continue independent content, never retry. | |||
                summary["invalid"] += 1 | |||
                continue | |||
            try: | |||
                metadata, message = admin.read_event(root / "pending", event_id) | |||
            except FileNotFoundError: | |||
                continue | |||
            except (OSError, ValueError, TypeError, FeedbackError): | |||
                summary["invalid"] += 1 | |||
                continue | |||
            # Do not act if the queued ordering/identity changed since enumeration. | |||
            if any(metadata.get(key) != candidate.get(key) for key in | |||
                   ("sha256", "label", "created_ns", "created_utc")): | |||
                summary["invalid"] += 1 | |||
                continue | |||
            if used_bytes + 2 * len(message) + 32768 > admin.collector.MAX_PENDING_BYTES: | |||
                summary["storage_blocked"] = True | |||
                break | |||
            label, digest = metadata["label"], metadata["sha256"] | |||
            if candidate.get("_ordering_invalid"): | |||
                status, reason = "quarantined_ordering", "event ordering is ambiguous" | |||
            elif digest in blocked: | |||
                status, reason = "quarantined_uncertain", "a prior learning operation has an uncertain result" | |||
            elif digest in latest and latest[digest][1] == label: | |||
                status, reason = "duplicate", "same class as the latest successful learning operation" | |||
            else: | |||
                status, reason = "learning", "automatic explicit IMAP feedback" | |||
            try: | |||
                archive, receipt = admin.prepare_archive(root, event_id, metadata, message, status, label, reason) | |||
            except (OSError, ValueError, FeedbackError): | |||
                # prepare_archive writes a root journal before moving the source; | |||
                # stop instead of retrying an incompletely prepared operation. | |||
                summary["storage_blocked"] = True | |||
                break | |||
            sequence += 1 | |||
            receipt.update({"mode": "automatic_imap_feedback", "worker_sequence": sequence, | |||
                            "processed_ns": time.time_ns(), "source_created_ns": candidate["_order_ns"]}) | |||
            if digest in latest: | |||
                receipt["previous_successful_class"] = latest[digest][1] | |||
            # Durable intent exists before the only call that may change Bayes. | |||
            admin.update_receipt(archive, receipt) | |||
            if status == "learning": | |||
                try: | |||
                    returncode = learner(message, label, executable=executable, controller=controller, | |||
                                         password_file=password_file, timeout=timeout) | |||
                except (subprocess.TimeoutExpired, OSError, FeedbackError): | |||
                    receipt["status"] = "learn_uncertain" | |||
                    receipt["error"] = "client failure; server may have learned; no automatic retry" | |||
                else: | |||
                    receipt["returncode"] = returncode | |||
                    receipt["status"] = "learned" if returncode == 0 else "learn_uncertain" | |||
                    if returncode != 0: | |||
                        receipt["error"] = "client returned failure; no automatic retry" | |||
                receipt["finished_utc"] = admin.collector.utc_now() | |||
                admin.update_receipt(archive, receipt) | |||
                if receipt["status"] == "learned": | |||
                    latest[digest] = ((True, sequence, event_id), label) | |||
                    summary["learned"] += 1 | |||
                else: | |||
                    blocked.add(digest) | |||
                    summary["quarantined"] += 1 | |||
            elif status == "duplicate": | |||
                summary["duplicates"] += 1 | |||
            else: | |||
                blocked.add(digest) | |||
                summary["quarantined"] += 1 | |||
            handled += 1 | |||
            used_bytes += 2 * len(message) + 32768 | |||
            archive_count += 1 | |||
            summary["pending"] -= 1 | |||
            summary["archive_warning"] = archive_count >= admin.collector.MAX_PENDING_EVENTS | |||
            log_event("feedback_processed", id=event_id, label=label, status=receipt["status"]) | |||
            if receipt["status"] == "learn_uncertain": | |||
                # The controller may have failed after the preflight. Leave all | |||
                # other candidates pending until a fresh read-only probe passes. | |||
                break | |||
    return summary | |||
def main(argv=None): | |||
    parser = argparse.ArgumentParser(description=__doc__) | |||
    parser.add_argument("--queue-root", default=admin.DEFAULT_ROOT) | |||
    parser.add_argument("--rspamc-bin", default="/usr/local/bin/rspamc") | |||
    parser.add_argument("--controller", default="127.0.0.1:11334") | |||
    parser.add_argument("--password-file", default=admin.DEFAULT_PASSWORD_FILE) | |||
    parser.add_argument("--poll", type=float, default=2) | |||
    parser.add_argument("--timeout", type=float, default=30) | |||
    parser.add_argument("--batch-size", type=int, default=20) | |||
    parser.add_argument("--once", action="store_true") | |||
    args = parser.parse_args(argv) | |||
    os.umask(0o077) | |||
    admin.require_root() | |||
    root = Path(args.queue_root) | |||
    if not root.is_absolute() or not 0.2 <= args.poll <= 60 or not 0 < args.timeout <= 300 or not 1 <= args.batch_size <= 100: | |||
        raise FeedbackError("invalid worker options") | |||
    admin.check_root_directory(root) | |||
    stopping = threading.Event() | |||
    for signum in (signal.SIGTERM, signal.SIGINT): | |||
        signal.signal(signum, lambda *_: stopping.set()) | |||
    lockfd = os.open(root / ".worker.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) | |||
    try: | |||
        if os.name == "posix": | |||
            import fcntl | |||
            fcntl.flock(lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB) | |||
        previous_state = None | |||
        while not stopping.is_set(): | |||
            try: | |||
                summary = process_pending(root, executable=args.rspamc_bin, controller=args.controller, | |||
                                          password_file=args.password_file, timeout=args.timeout, | |||
                                          batch_size=args.batch_size, stop_event=stopping) | |||
                state = {key: value for key, value in summary.items() if key not in ("learned", "duplicates", "quarantined")} | |||
                if state != previous_state or any(summary[key] for key in ("learned", "duplicates", "quarantined")): | |||
                    log_event("worker_state", **summary) | |||
                previous_state = state | |||
                if args.once: | |||
                    return 2 if summary["controller_unavailable"] or summary["storage_blocked"] else 0 | |||
            except (OSError, ValueError, TypeError, FeedbackError): | |||
                if previous_state != "local_error": | |||
                    log_event("worker_error", reason="local queue, journal or configuration failure; no automatic retry of uncertain training") | |||
                previous_state = "local_error" | |||
                if args.once: | |||
                    return 1 | |||
            stopping.wait(args.poll) | |||
        return 0 | |||
    finally: | |||
        os.close(lockfd) | |||
if __name__ == "__main__": | |||
    try: | |||
        raise SystemExit(main()) | |||
    except (OSError, ValueError, TypeError, FeedbackError): | |||
        log_event("worker_start_failed", reason="root, configuration or worker lock required") | |||
        raise SystemExit(1) | |||
</pre> | |||
==== Обёртки запуска и права ==== | |||
Создать /usr/local/libexec/rspamd-feedback/collector: | |||
<pre> | |||
#!/bin/sh | |||
exec /usr/local/bin/python3.12 /usr/local/libexec/rspamd-feedback/feedback.py "$@" | |||
</pre> | |||
Создать /usr/local/sbin/rspamd-feedback: | |||
<pre> | |||
#!/bin/sh | |||
exec /usr/local/bin/python3.12 /usr/local/libexec/rspamd-feedback/feedback-admin.py "$@" | |||
</pre> | |||
<pre> | |||
chown root:wheel /usr/local/libexec/rspamd-feedback/* /usr/local/sbin/rspamd-feedback | |||
chmod 0755 /usr/local/libexec/rspamd-feedback/* /usr/local/sbin/rspamd-feedback | |||
python3.12 -m py_compile /usr/local/libexec/rspamd-feedback/feedback.py /usr/local/libexec/rspamd-feedback/feedback-admin.py /usr/local/libexec/rspamd-feedback/feedback-worker.py | |||
</pre> | |||
=== 15.5. Подключить IMAPSieve === | |||
Сохранить /usr/local/etc/dovecot/rspamd-feedback.conf как root:wheel 0644. Блок inbox дополняет существующий namespace, не заменяет его. | |||
<pre> | |||
# Dovecot CE 2.4.5 / Pigeonhole 2.4: local IMAP feedback collection for the background worker. | |||
# Include explicitly from the end of the existing monolithic dovecot.conf. | |||
# Install root:wheel 0644. Precompile the two administrator scripts with sievec. | |||
# No Sieve plugin is enabled for LMTP/LDA and no incoming mail is moved. | |||
protocol imap { | |||
  mail_plugins { | |||
    imap_sieve = yes | |||
  } | |||
} | |||
sieve_plugins { | |||
  sieve_imapsieve = yes | |||
  sieve_extprograms = yes | |||
} | |||
# External commands are available only to administrator-controlled scripts. | |||
sieve_global_extensions { | |||
  vnd.dovecot.pipe = yes | |||
  vnd.dovecot.environment = yes | |||
} | |||
sieve_pipe_bin_dir = /usr/local/libexec/rspamd-feedback | |||
sieve_pipe_exec_timeout = 3s | |||
# The existing inbox namespace is private, has no prefix, and uses '/' as its | |||
# separator. Exact names deliberately exclude shared namespaces, subfolders, | |||
# Trash/Sent/Archive, and all movements other than these two directions. | |||
namespace inbox { | |||
  mailbox Junk { | |||
    auto = subscribe | |||
    special_use = \Junk | |||
    imapsieve_from INBOX { | |||
      sieve_script rspamd-report-spam { | |||
        type = before | |||
        cause = copy | |||
        path = /usr/local/etc/dovecot/rspamd/report-spam.sieve | |||
      } | |||
    } | |||
  } | |||
  mailbox INBOX { | |||
    imapsieve_from Junk { | |||
      sieve_script rspamd-report-ham { | |||
        type = before | |||
        cause = copy | |||
        path = /usr/local/etc/dovecot/rspamd/report-ham.sieve | |||
      } | |||
    } | |||
  } | |||
} | |||
# imapsieve_url is intentionally unset: no user-uploaded IMAPSieve scripts. | |||
# COPY is also the IMAPSieve cause for a client-issued IMAP MOVE command. | |||
# The collector spools reports; the separate root worker trains Bayes automatically. | |||
</pre> | |||
В конце основного /usr/local/etc/dovecot/dovecot.conf добавить ровно одну строку: | |||
<pre> | |||
!include /usr/local/etc/dovecot/rspamd-feedback.conf | |||
</pre> | |||
/usr/local/etc/dovecot/rspamd/report-spam.sieve: | |||
<pre> | |||
require ["vnd.dovecot.pipe", "copy", "vnd.dovecot.imapsieve", | |||
         "vnd.dovecot.environment", "environment", "variables"]; | |||
# Recheck the event as well as the Dovecot configuration filters. This excludes | |||
# APPEND/FLAG, shared-folder paths, subfolders and non-INBOX source mailboxes. | |||
if not allof ( | |||
    environment :is "imap.cause" "COPY", | |||
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-from" "INBOX", | |||
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-to" "Junk" | |||
) { | |||
    stop; | |||
} | |||
if environment :matches "imap.user" "*" { | |||
    set "username" "${1}"; | |||
} | |||
if string :is "${username}" "" { | |||
    stop; | |||
} | |||
# Fixed program name; all metadata are separate argv entries, never shell text. | |||
# :copy preserves the message; :try keeps feedback failures non-fatal. | |||
pipe :copy :try "collector" ["spam", "${username}", "INBOX", "Junk"]; | |||
</pre> | |||
/usr/local/etc/dovecot/rspamd/report-ham.sieve: | |||
<pre> | |||
require ["vnd.dovecot.pipe", "copy", "vnd.dovecot.imapsieve", | |||
         "vnd.dovecot.environment", "environment", "variables"]; | |||
# Only restoring a message from Junk to INBOX constitutes a ham report. | |||
# Moving to Trash/Sent/Archive, APPEND, FLAG, and shared folders are ignored. | |||
if not allof ( | |||
    environment :is "imap.cause" "COPY", | |||
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-from" "Junk", | |||
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-to" "INBOX" | |||
) { | |||
    stop; | |||
} | |||
if environment :matches "imap.user" "*" { | |||
    set "username" "${1}"; | |||
} | |||
if string :is "${username}" "" { | |||
    stop; | |||
} | |||
# The collector stores a pending report; the background root worker learns it. | |||
pipe :copy :try "collector" ["ham", "${username}", "Junk", "INBOX"]; | |||
</pre> | |||
<pre> | |||
chown root:wheel /usr/local/etc/dovecot/rspamd-feedback.conf /usr/local/etc/dovecot/rspamd/*.sieve | |||
chmod 0644 /usr/local/etc/dovecot/rspamd-feedback.conf /usr/local/etc/dovecot/rspamd/*.sieve | |||
doveconf -n | |||
sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/report-spam.sieve | |||
sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/report-ham.sieve | |||
chown root:wheel /usr/local/etc/dovecot/rspamd/*.svbin | |||
chmod 0644 /usr/local/etc/dovecot/rspamd/*.svbin | |||
service dovecot restart | |||
service dovecot status | |||
</pre> | |||
Рестарт Dovecot кратковременно разрывает существующие IMAP-сеансы; клиенты подключатся заново. Sieve :copy сохраняет письмо, :try не делает ошибку сборщика причиной отказа пользовательского действия. Выполнение внешнего сборщика ограничено 3 секундами, ожидание его блокировки — 2 секундами. LMTP-сортировка не нужна для обучения и на этом шаге ещё не включена. | |||
=== 15.6. Запустить автоматический обработчик === | |||
Сохранить /usr/local/etc/rc.d/rspamd_feedback как root:wheel 0555: | |||
<pre> | |||
#!/bin/sh | |||
# PROVIDE: rspamd_feedback | |||
# REQUIRE: LOGIN redis rspamd | |||
# KEYWORD: shutdown | |||
# | |||
# Install as /usr/local/etc/rc.d/rspamd_feedback, root:wheel 0555. | |||
# Enable with: sysrc rspamd_feedback_enable=YES | |||
# Uses FreeBSD 15.1 daemon(8): rc.subr targets the supervisor PID (-P), | |||
# not the child PID (-p), so stop cannot accidentally restart the worker. | |||
. /etc/rc.subr | |||
name="rspamd_feedback" | |||
desc="Apply user spam/ham feedback to the local Rspamd Bayes classifier" | |||
rcvar="rspamd_feedback_enable" | |||
load_rc_config "${name}" | |||
: ${rspamd_feedback_enable:="NO"} | |||
command="/usr/sbin/daemon" | |||
procname="/usr/sbin/daemon" | |||
pidfile="/var/run/rspamd_feedback.pid" | |||
child_pidfile="/var/run/rspamd_feedback.child.pid" | |||
worker="/usr/local/libexec/rspamd-feedback/feedback-worker.py" | |||
python="/usr/local/bin/python3.12" | |||
logdir="/opt/mail/log/rspamd-feedback" | |||
logfile="${logdir}/worker.log" | |||
required_files="${python} ${worker} /usr/local/libexec/rspamd-feedback/feedback-admin.py /usr/local/etc/rspamd/controller.password" | |||
required_dirs="/opt/mail/rspamd-feedback/pending /opt/mail/rspamd-feedback/processed /opt/mail/rspamd" | |||
# Worker handles transient controller errors in its polling loop. Unexpected | |||
# exits get at most five supervised restarts, spaced 15 seconds apart. | |||
# -H reopens the log on SIGHUP without interrupting an in-flight learn request. | |||
command_args="-c -f -H -R 15 -C 5 -P ${pidfile} -p ${child_pidfile} -t ${name} -o ${logfile} ${python} -u ${worker} --poll 2" | |||
start_precmd="rspamd_feedback_prestart" | |||
extra_commands="reload" | |||
sig_reload="HUP" | |||
rspamd_feedback_prestart() | |||
{ | |||
    if [ "$(id -u)" -ne 0 ]; then | |||
        warn "${name} requires root to read the local controller credential" | |||
        return 1 | |||
    fi | |||
    # Refuse to write queue/log data to the root filesystem when /opt failed | |||
    # to mount. Directory existence alone does not establish the mount state. | |||
    if ! /sbin/mount -p | /usr/bin/awk '$2 == "/opt" { found = 1 } END { exit !found }'; then | |||
        warn "${name} requires the mail filesystem mounted at /opt" | |||
        return 1 | |||
    fi | |||
    if [ -L "${logdir}" ] || [ -L "${logfile}" ]; then | |||
        warn "${name} refuses a symlink at its private log path" | |||
        return 1 | |||
    fi | |||
    /usr/bin/install -d -o root -g wheel -m 0700 "${logdir}" || return 1 | |||
    if [ ! -e "${logfile}" ]; then | |||
        /usr/bin/install -o root -g wheel -m 0600 /dev/null "${logfile}" || return 1 | |||
    fi | |||
    /usr/sbin/chown root:wheel "${logfile}" || return 1 | |||
    /bin/chmod 0600 "${logfile}" || return 1 | |||
    umask 077 | |||
} | |||
run_rc_command "$1" | |||
</pre> | |||
Сохранить /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf как root:wheel 0644: | |||
<pre> | |||
# Install /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf root:wheel 0644. | |||
# FreeBSD daemon -H receives SIGHUP through its supervisor pidfile and reopens | |||
# the file. Keep seven compressed 1 MiB archives; active and rotated logs 0600. | |||
/opt/mail/log/rspamd-feedback/worker.log root:wheel 600 7 1024 * JC /var/run/rspamd_feedback.pid 1 | |||
</pre> | |||
<pre> | |||
chown root:wheel /usr/local/etc/rc.d/rspamd_feedback /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf | |||
chmod 0555 /usr/local/etc/rc.d/rspamd_feedback | |||
chmod 0644 /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf | |||
sh -n /usr/local/etc/rc.d/rspamd_feedback | |||
newsyslog -n -f /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf | |||
rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat | |||
sysrc rspamd_feedback_enable=YES | |||
service rspamd_feedback start | |||
service rspamd_feedback status | |||
tail -50 /opt/mail/log/rspamd-feedback/worker.log | |||
</pre> | |||
Супервизор daemon имеет отдельные PID-файлы родителя и ребёнка: stop завершает супервизор, не вызывает немедленный перезапуск worker. После неожиданного выхода предусмотрено не более пяти перезапусков с паузой 15 секунд. Проверка монтирования /opt выполняется до открытия журналов. | |||
=== 15.7. Необязательно: автоматическая доставка отмеченного спама в Junk === | |||
Этот небольшой отдельный этап помещает новые письма с локальной X-Spam-Flag: YES в Junk. Остальная почта сохраняется во Входящих. Уже лежащие сообщения не перемещаются. Если нужна только обратная связь при ручных перемещениях, этот этап можно пропустить. Автоматическая LMTP-доставка в Junk сама по себе не создаёт IMAP COPY и не обучает фильтр; исправление пользователем Junk → INBOX создаст ham-событие. | |||
Сохранить /usr/local/etc/dovecot/rspamd-spam-delivery.conf как root:wheel 0644: | |||
<pre> | |||
# Dovecot CE / Pigeonhole 2.4.5: sort marked mail during LMTP delivery only. | |||
# Include explicitly from the existing monolithic dovecot.conf. | |||
# Install this fragment and spam-delivery.sieve root:wheel 0644, and precompile | |||
# the script with sievec -c /usr/local/etc/dovecot/dovecot.conf; make the resulting | |||
# .svbin root:wheel 0644 so the vmail LMTP process can read it. | |||
protocol lmtp { | |||
  mail_plugins { | |||
    sieve = yes | |||
  } | |||
  sieve_script rspamd-spam-delivery { | |||
    type = before | |||
    cause = delivery | |||
    path = /usr/local/etc/dovecot/rspamd/spam-delivery.sieve | |||
  } | |||
} | |||
# Existing IMAP COPY feedback scripts remain separate. LMTP fileinto does not | |||
# produce an IMAP COPY event, so automatic filing cannot train the classifier. | |||
</pre> | |||
Сохранить /usr/local/etc/dovecot/rspamd/spam-delivery.sieve как root:wheel 0644: | |||
<pre> | |||
require ["fileinto", "mailbox"]; | |||
# Exim removes untrusted X-Spam-Flag and adds the local Rspamd result. | |||
# This administrator script is used only for LMTP delivery, not IMAP events. | |||
if header :is "X-Spam-Flag" "YES" { | |||
    fileinto :create "Junk"; | |||
    stop; | |||
} | |||
# Unmarked messages use implicit keep (INBOX in the current configuration). | |||
</pre> | |||
В конец основного dovecot.conf добавить единственную строку: | |||
<pre> | |||
!include /usr/local/etc/dovecot/rspamd-spam-delivery.conf | |||
</pre> | |||
<pre> | |||
doveconf -n | |||
sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/spam-delivery.sieve | |||
chown root:wheel /usr/local/etc/dovecot/rspamd/spam-delivery.svbin | |||
chmod 0644 /usr/local/etc/dovecot/rspamd/spam-delivery.svbin | |||
service dovecot restart | |||
service dovecot status | |||
</pre> | |||
=== 15.8. Сквозная проверка обучения === | |||
Использовать отдельный тестовый ящик из уже настроенного домена; войти в него полным адресом через IMAP или SnappyMail. В клиенте папка Junk должна иметь специальный флаг \Junk и может отображаться как «Спам». Если папки нет в списке — обновить список/подписку. | |||
# Проверить доставку обычного письма и локальные заголовки X-Spam-Score/X-Rspamd-Action. Поддельный X-Spam-Flag внешнего отправителя должен быть очищен и заменён результатом сервера. | |||
# Отправить безвредное тестовое письмо себе. Переместить INBOX → Junk. В worker.log должен появиться feedback_processed с label=spam и status=learned, а событие перейти из pending в processed. | |||
# Вернуть то же письмо Junk → INBOX: появится новое событие ham. Для проверки повторного исправления можно пройти spam → ham → spam → ham. | |||
# Повтор той же копии с тем же классом должен иметь status=duplicate, не увеличивая вес примера повторно. | |||
# Junk → Trash, изменение флагов и добавление письма сразу в папку не должны создавать обучающие события. | |||
# Если включена сортировка, новая доставка в Junk должна обходиться без обратной связи; ручной возврат такого письма во Входящие должен дать ровно один ham-event. | |||
# Отдельно проверить, что обычная почта доставляется при недоступном Rspamd, а ClamAV по-прежнему отклоняет EICAR. Остановку служб для этой проверки выполнять в согласованное окно или на тестовом экземпляре. | |||
Для тестов, которые не должны обучать рабочую Bayes-базу, нужен отдельный экземпляр Rspamd с изолированной Redis-базой. Примеры обычных пользователей в тестовую базу не копировать. Синтетические события после теста удаляют только вместе с явно выделенным тестовым ящиком/изолированной очередью. | |||
<pre> | |||
service rspamd_feedback status | |||
rspamd-feedback list | |||
rspamd-feedback list --state processed | |||
rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat | |||
tail -50 /opt/mail/log/rspamd-feedback/worker.log | |||
exim -bpc | |||
</pre> | |||
Просмотр одного события, подставив его фактический ID из list: | |||
<pre> | |||
rspamd-feedback show ID --state processed | |||
</pre> | |||
=== 15.9. Ограничения, очередь и ошибки === | |||
Очередь pending ограничена 1024 событиями и 512 MiB; одно письмо — не более 50 MiB. Сборщик сохраняет резерв свободного места 1 GiB плюс максимальное письмо и метаданные. Архив processed содержит root-only снимок и сохранённый исходный event; архив сам не очищается. После 1024 записей выдаётся предупреждение, при 512 MiB worker приостанавливает дальнейшую обработку до обслуживания архива. Нельзя без разбора удалять журнал processed: он нужен для подавления повторов и сохранения неопределённых результатов. | |||
При controller_unavailable проверяются служба Rspamd, loopback 11334, файл controller.password и права. Пока контроллер заведомо недоступен, события остаются pending и могут быть обработаны после восстановления. Status learn_uncertain означает, что запрос уже мог изменить Bayes: автоматического повтора нет; последующие события того же содержимого изолируются для ручного разбора. Не переименовывать такие события обратно в pending и не запускать learn повторно вслепую. | |||
Обратная связь не подтверждает, что класс письма объективно верен: это пользовательский сигнал общей статистической базе. Не выставлять контроллер Rspamd или Redis в интернет и не передавать пароль контроллера Dovecot, PHP либо vmail. | |||
=== 15.10. Отключение без удаления почты и статистики === | |||
Остановить только автоматическое обучение: | |||
<pre> | |||
service rspamd_feedback stop | |||
sysrc rspamd_feedback_enable=NO | |||
</pre> | |||
Оценка Rspamd, доставка и IMAPSieve-сбор событий продолжат работать, уже обученная Bayes-база сохраняется. Пока worker выключен, следить за размером pending. | |||
Чтобы отключить только автоматическую сортировку, убрать из основного dovecot.conf строку !include /usr/local/etc/dovecot/rspamd-spam-delivery.conf, выполнить doveconf -n и service dovecot restart. Обучение по пользовательским перемещениям сохранится. | |||
Для полного отключения обратной связи остановить worker и убрать include rspamd-feedback.conf из dovecot.conf, проверить конфигурацию и перезапустить Dovecot. Для отключения оценки убрать только добавленный блок Rspamd из acl_check_data и spamd_address, проверить exim -bV и перечитать Exim. Если оставляется правило LMTP-сортировки, очистку чужих X-Spam-заголовков необходимо сохранить; проще сначала отключить сортировку. Правила ClamAV, пользовательский каталог и Maildir не удалять. Redis/Rspamd останавливать лишь после исключения зависимых функций. | |||
=== Источники к настройке Rspamd === | |||
* [https://docs.rspamd.com/getting-started/first-setup/ Rspamd: Redis, контроллер, secure_ip и первоначальные настройки] | |||
* [https://docs.rspamd.com/administration/rspamadm/cryptography/ Rspamd: генерация и проверка хеша пароля] | |||
* [https://doc.dovecot.org/2.4.2/core/plugins/imap_sieve Dovecot 2.4: IMAPSieve и imapsieve_from] | |||
* [https://www.exim.org/exim-html-current/doc/html/spec_html/ch-content_scanning_at_acl_time.html Exim: Rspamd и defer_ok в ACL] | |||
== Первичные источники == | == Первичные источники == | ||
Текущая версия от 02:25, 29 октября 2026
Почтовый сервер на FreeBSD 15.1: Exim и Dovecot
Самостоятельная инструкция для одного сервера: Exim принимает SMTP и submission, Dovecot предоставляет IMAPS, а доставка в Maildir выполняется через локальный LMTP-сокет. Пользователи и алиасы хранятся в файлах, SQL не требуется. Почта, очередь и дополнительные данные размещаются на отдельной UFS в /opt. Сертификат выпускается после подготовки конфигураций: до этого сетевые почтовые службы не запускаются.
Все имена и адреса ниже — примеры. Заменить example.org и example.net своими доменами, mail.example.org — своим FQDN, 192.0.2.10 — реальным публичным IPv4, 198.51.100.20 — адресом сервера в LAN. 192.0.2.0/24 и 198.51.100.0/24 зарезервированы для документации (RFC 5737) и не являются рабочими адресами инфраструктуры. В примерах 198.51.100.20 — условный LAN-адрес сервера, 198.51.100.1 — условный шлюз; заменить их своими значениями. Домены example.org и example.net также предназначены для примеров. Публичные адреса документации, поставщиков и источников баз приведены намеренно; учётные данные для доступа к ним не публикуются. Команды для чистой системы выполняются от root в /bin/sh; существующие конфигурации сначала сравнить, не заменять вслепую. Проверенная основа: FreeBSD 15.1, Exim 4.100, Dovecot 2.4.5. Для других веток Dovecot синтаксис отличается.
1. Схема и требования
Интернет → TCP 25 / STARTTLS → Exim Клиент → TCP 465 TLS или 587 STARTTLS + AUTH → Exim Exim → LMTP Unix socket → Dovecot → /opt/mail/vhosts/domain/user/Maildir Exim → AUTH Unix socket → Dovecot → файловый каталог пользователей Клиент → TCP 993 TLS → Dovecot SnappyMail (необязательно) → те же IMAPS и SMTP AUTH
- Выделенный пустой диск da1; системный диск — другой диск. Тип ФС — UFS.
- Статический адрес сервера и действующий маршрут по умолчанию. При NAT пробросить TCP 25, 465, 587, 993 на сервер. Для SnappyMail понадобится HTTPS 443 или отдельный обратный прокси.
- Провайдер должен разрешать исходящий и входящий TCP 25. PTR публичного IP заказывается у владельца адреса: mail.example.org. Прямая A-запись должна возвращать тот же IP.
- Основной вариант IPv4. AAAA не публиковать, пока IPv6 не настроен полностью; в приведенном Exim IPv6 выключен.
- Не публиковать MX нового сервера до запуска и проверки. Смена MX переключает новые письма, но не переносит старую почту.
- Антиспам и фаервол не входят в базовую последовательность. Rspamd — отдельный этап, ClamAV описан ниже.
2. Проверить систему без изменений
id freebsd-version -kru uname -m hostname -f ifconfig -a netstat -rn cat /etc/resolv.conf mount -p df -hT swapinfo -h geom disk list da1 gpart show -p gpart show -p da1 glabel status fstyp /dev/da1 file -s /dev/da1 sockstat -46l ls -ld /opt cat /etc/fstab
При необходимости настроить имя и шлюз, подставив реальные параметры сети. Если интерфейс уже настроен, не применять ifconfig повторно через удалённый сеанс без необходимости.
sysrc hostname=mail.example.org hostname mail.example.org sysrc defaultrouter=198.51.100.1 # Только если в работающей таблице отсутствует default route: route add default 198.51.100.1
3. Однократно создать UFS на пустом da1
ВНИМАНИЕ: gpart create и newfs — разрушающие команды. На диске с существующими данными их повторять нельзя. Проверить модель, размер, серийный номер, разделы, метки, mount, swap и GEOM-зависимости. Отсутствие распознанной ФС не доказывает, что диск пуст. /opt не должен содержать файлов, которые скроются после монтирования.
Для нового диска, который должен быть полностью нулевым, дополнительная проверка чтением всех байтов:
disk_bytes=$(diskinfo /dev/da1 | awk '{print $3}')
test -n "$disk_bytes" && test "$disk_bytes" -gt 0 || exit 1
cmp -s -n "$disk_bytes" /dev/da1 /dev/zero
printf 'cmp status: %s\n' "$?"
0 означает нулевое содержимое, 1 — ненулевые данные, больше 1 — ошибку проверки. В двух последних случаях остановиться и разобраться. На уже созданной UFS результат 1 нормален и не является поводом форматировать её.
Только после проверки действительно пустого выделенного диска:
gpart create -s gpt da1 gpart add -a 1m -t freebsd-ufs -l mailopt da1 gpart show -pl da1 newfs -U -L mailopt /dev/gpt/mailopt install -d -o root -g wheel -m 0755 /opt mount -o rw,noatime /dev/gpt/mailopt /opt df -hT /opt
В /etc/fstab добавить ровно одну запись, предварительно исключив дубликаты:
/dev/gpt/mailopt /opt ufs rw,noatime 2 2
При повторной установке существующую UFS проверяют и монтируют, не создают заново.
4. Пакеты, пользователи и каталоги
pkg update pkg install -n exim dovecot24 swaks ca_root_nss acme.sh # После проверки плана: pkg install -y exim dovecot24 swaks ca_root_nss acme.sh sysrc exim_enable=NO dovecot_enable=NO sysrc sendmail_enable=NONE sendmail_submit_enable=NO sysrc sendmail_outbound_enable=NO sendmail_msp_queue_enable=NO service sendmail onestop /usr/local/sbin/exim -bV /usr/local/sbin/dovecot --version pw usershow mailnull pw groupshow mail pw groupshow dovecot
Ошибку «служба sendmail не запущена» можно принять только после проверки, что другой SMTP-процесс не занимает порт 25. Существующий рабочий почтовый сервер нельзя останавливать этой последовательностью без миграции.
В пакете Exim должны присутствовать OpenSSL, Dovecot authenticator, LMTP и Content_Scanning для необязательного ClamAV. Проверить, что UID и GID 5000 свободны. Если заняты, выбрать другие и согласованно заменить во всех дальнейших настройках.
pw usershow -u 5000 pw groupshow -g 5000 # Следующие две команды только если идентификаторы свободны: pw groupadd vmail -g 5000 pw useradd vmail -u 5000 -g vmail -d /opt/mail -s /usr/sbin/nologin -w no install -d -o root -g wheel -m 0755 /opt/mail install -d -o root -g vmail -m 0750 /opt/mail/vhosts install -d -o mailnull -g mail -m 0750 /opt/mail/exim-spool /var/log/exim install -d -o root -g mail -m 0750 /usr/local/etc/exim install -d -o root -g dovecot -m 0750 /usr/local/etc/virtual-mail install -d -o root -g mail -m 0750 /usr/local/etc/exim/dkim /usr/local/etc/mail-certs install -d -o root -g wheel -m 0755 /etc/rc.conf.d
Создать файлы, если они отсутствуют; не обнулять уже существующий каталог:
touch /usr/local/etc/virtual-mail/users
chown root:dovecot /usr/local/etc/virtual-mail/users
chmod 0640 /usr/local/etc/virtual-mail/users
for name in domains aliases mailboxes sender_owners; do
touch /usr/local/etc/exim/$name
chown root:mail /usr/local/etc/exim/$name
chmod 0640 /usr/local/etc/exim/$name
done
В /etc/rc.conf.d/exim сохранить:
required_dirs="/opt/mail/exim-spool /opt/mail/vhosts"
В /etc/rc.conf.d/dovecot:
required_dirs="/opt/mail/vhosts"
5. Полная конфигурация Exim
Сохранить следующий текст как /usr/local/etc/exim/configure, владелец root:mail, режим 0640. Это конфигурация без ClamAV; антивирус добавляется отдельным этапом. На 465/587 обязательны TLS и AUTH; на 25 AUTH не предлагается. Межсерверный SMTP 25 использует STARTTLS по возможности. Relay без аутентификации запрещён, включая localhost и LAN. Получатели обслуживаемых доменов проверяются до приема письма; авторизованный пользователь может отправлять только со своих адресов и алиасов.
# FreeBSD 15.1 / Exim / virtual users / Dovecot LMTP and AUTH.
# Render placeholders, validate with exim -C ... -bV, then install as
# /usr/local/etc/exim/configure. Do not start before certificates exist.
# Runtime identity in the official FreeBSD package is mailnull:mail.
primary_hostname = mail.example.org
qualify_domain = example.org
keep_environment =
local_interfaces = 127.0.0.1 : 198.51.100.20
disable_ipv6 = true
domainlist local_domains = @ : /usr/local/etc/exim/domains
spool_directory = /opt/mail/exim-spool
log_file_path = /var/log/exim/%slog
never_users = root
message_size_limit = 50M
daemon_smtp_ports = 25 : 465 : 587
tls_on_connect_ports = 465
tls_advertise_hosts = *
tls_certificate = /usr/local/etc/mail-certs/fullchain.pem
tls_privatekey = /usr/local/etc/mail-certs/privkey.pem
# This option requires an OpenSSL build (the default FreeBSD port).
# Preserve the standard safety flags while disabling TLS 1.0 and 1.1.
openssl_options = +no_sslv2 +no_sslv3 +single_dh_use +no_ticket +no_renegotiation +no_tlsv1 +no_tlsv1_1
tls_require_ciphers = HIGH:!aNULL:!eNULL:!MD5:!RC4:!3DES
acl_smtp_auth = acl_check_auth
acl_smtp_mail = acl_check_mail
acl_smtp_rcpt = acl_check_rcpt
acl_smtp_data = acl_check_data
acl_smtp_vrfy = deny
acl_smtp_expn = deny
begin acl
acl_check_auth:
deny
condition = ${if !inlist{$received_port}{465:587}{yes}{no}}
message = Authentication is available only on submission ports
accept
encrypted = *
deny
message = TLS is required before authentication
acl_check_mail:
deny
condition = ${if inlist{$received_port}{465:587}{yes}{no}}
!encrypted = *
message = TLS is required on submission ports
deny
condition = ${if inlist{$received_port}{465:587}{yes}{no}}
!authenticated = *
message = Authentication is required on submission ports
deny
authenticated = *
condition = ${if !inlisti{${lc:$authenticated_id}}{${lookup{${lc:$sender_address}}lsearch{/usr/local/etc/exim/sender_owners}{$value}{}}}{yes}{no}}
message = Envelope sender is not authorized for this account
accept
acl_check_rcpt:
# Validate hosted recipients even when the sender is authenticated.
deny
domains = +local_domains
!verify = recipient
message = Unknown local recipient
accept
authenticated = *
accept
domains = +local_domains
# There is deliberately no localhost/LAN relay exemption.
deny
message = Relay not permitted
acl_check_data:
# Only submitted authenticated mail is subjected to sender ownership.
# Raw From preserves encoded display names containing commas.
deny
authenticated = *
!verify = header_syntax
message = Invalid message header syntax
warn
authenticated = *
set acl_m_from_addresses = ${addresses:>:$rh_from:}
deny
authenticated = *
condition = ${if !eq{${listcount:$acl_m_from_addresses}}{1}{yes}{no}}
message = A single valid From address is required
deny
authenticated = *
condition = ${if !inlisti{${lc:$authenticated_id}}{${lookup{${lc:$acl_m_from_addresses}}lsearch{/usr/local/etc/exim/sender_owners}{$value}{}}}{yes}{no}}
message = Header From address is not authorized for this account
accept
begin routers
service_hostname_postmaster:
driver = redirect
domains = @
local_parts = root : postmaster
data = alice@example.org
forbid_file
forbid_pipe
virtual_aliases:
driver = redirect
domains = +local_domains
data = ${lookup{${lc:$local_part@$domain}}lsearch{/usr/local/etc/exim/aliases}{$value}}
forbid_file
forbid_pipe
virtual_mailboxes:
driver = accept
domains = +local_domains
condition = ${lookup{${lc:$local_part@$domain}}lsearch{/usr/local/etc/exim/mailboxes}{yes}{no}}
transport = dovecot_lmtp
no_more
cannot_route_message = Unknown local recipient
remote_dns:
driver = dnslookup
domains = ! +local_domains
transport = remote_smtp
ignore_target_hosts = 0.0.0.0 : 127.0.0.0/8 : ::1
no_more
begin transports
remote_smtp:
driver = smtp
dkim_domain = ${lookup{${lc:$sender_address_domain}}lsearch,ret=key{/usr/local/etc/exim/domains}{$value}{}}
dkim_selector = mail
dkim_private_key = ${if exists{/usr/local/etc/exim/dkim/$dkim_domain.key}{/usr/local/etc/exim/dkim/$dkim_domain.key}{0}}
dkim_hash = sha256
dkim_canon = relaxed
dkim_strict = true
# Opportunistic STARTTLS is the normal Exim default for remote delivery.
tls_verify_certificates = system
dovecot_lmtp:
driver = lmtp
socket = /var/run/dovecot/lmtp-exim
user = mailnull
group = mail
batch_max = 20
# LMTP RCPT contains the full canonical local_part@domain.
begin retry
* * F,2h,15m; G,16h,1h,1.5; F,4d,6h
begin rewrite
begin authenticators
dovecot_plain:
driver = dovecot
public_name = PLAIN
server_socket = /var/run/dovecot/auth-exim
server_set_id = $auth1
server_advertise_condition = ${if and{{def:tls_in_cipher}{inlist{$received_port}{465:587}}}{yes}{no}}
dovecot_login:
driver = dovecot
public_name = LOGIN
server_socket = /var/run/dovecot/auth-exim
server_set_id = $auth1
server_advertise_condition = ${if and{{def:tls_in_cipher}{inlist{$received_port}{465:587}}}{yes}{no}}
6. Полная конфигурация Dovecot 2.4
Сохранить как /usr/local/etc/dovecot/dovecot.conf, root:wheel, 0644. Не подключать одновременно пакетное дерево conf.d с повторяющимися настройками. Важен type = auth-legacy у сокета Exim в Dovecot 2.4. TCP 143 и стандартный открытый LMTP-сокет отключены.
dovecot_config_version = 2.4.4
dovecot_storage_version = 2.4.4
# FreeBSD 15.1, installed Dovecot CE 2.4.5_2, Exim on the same host.
# vmail has UID/GID 5000. Mail hostname is mail.example.org.
# Install as /usr/local/etc/dovecot/dovecot.conf, root:wheel 0644.
# Keep dovecot_enable=NO until certificates exist and all checks pass.
# No include of the package's example conf.d tree: this is a complete config.
base_dir = /var/run/dovecot
state_dir = /var/db/dovecot
listen = 0.0.0.0
protocols = imap lmtp
hostname = mail.example.org
postmaster_address = postmaster@example.org
# One canonical login per mailbox; preserve the domain and normalize case.
auth_mechanisms = plain login
auth_allow_cleartext = no
auth_username_format = %{user | lower}
auth_verbose = yes
# One service UID/GID for all virtual mailboxes.
first_valid_uid = 5000
last_valid_uid = 5000
first_valid_gid = 5000
last_valid_gid = 5000
mail_driver = maildir
mail_path = ~/Maildir
mail_uid = vmail
mail_gid = vmail
# Same file provides passdb and an existence-checking userdb. In particular,
# do not replace userdb with a static backend that accepts arbitrary names.
# File mode/owner: root:dovecot 0640. All entries use full user@domain.
passdb passwd-file {
passwd_file_path = /usr/local/etc/virtual-mail/users
}
userdb passwd-file {
passwd_file_path = /usr/local/etc/virtual-mail/users
}
# IMAP access is TLS-only on TCP 993. Never run the service before the last
# certificate-creation/installation step. These settings do not issue a cert.
ssl = required
ssl_min_protocol = TLSv1.2
ssl_server_cert_file = /usr/local/etc/mail-certs/fullchain.pem
ssl_server_key_file = /usr/local/etc/mail-certs/privkey.pem
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
}
# Exim's Dovecot authenticator currently needs auth-legacy with Dovecot 2.4.
# root creates the socket for Exim; mailnull is the FreeBSD Exim account.
service auth {
unix_listener auth-exim {
type = auth-legacy
user = mailnull
group = mail
mode = 0600
}
# Local LMTP runs as vmail and uses this userdb socket for lookups.
unix_listener auth-userdb {
user = vmail
group = vmail
mode = 0600
}
}
# Only the explicitly named Exim socket may receive mail. Disable the
# package/default LMTP listener, whose default mode is otherwise 0666.
service lmtp {
user = vmail
unix_listener lmtp {
mode = 0
}
unix_listener lmtp-exim {
user = mailnull
group = mail
mode = 0600
}
}
# Standard folders let desktop clients and future SnappyMail agree on names.
# Junk may be added later with an optional spam workflow.
namespace inbox {
inbox = yes
separator = /
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Sent {
auto = subscribe
special_use = \Sent
}
mailbox Trash {
auto = subscribe
special_use = \Trash
}
mailbox Archive {
auto = subscribe
special_use = \Archive
}
}
# Official references checked while preparing this template:
# https://doc.dovecot.org/2.4.4/howto/sasl/exim.html
# https://doc.dovecot.org/2.4.5/core/config/auth/databases/passwd_file.html
# https://doc.dovecot.org/2.4.5/core/config/delivery/lmtp.html
# https://doc.dovecot.org/2.4.4/core/config/namespaces.html
# https://doc.dovecot.org/latest/core/config/ssl.html
7. Управление доменами, ящиками, паролями и алиасами
Одна каноническая запись users имеет формат:
alice@example.org:{BLF-CRYPT}REPLACE_WITH_REAL_HASH:5000:5000::/opt/mail/vhosts/example.org/alice::
Это лишь пояснение формата, не рабочий пароль. Пароли вводятся интерактивно через doveadm; передавать их аргументом командной строки не следует. Полный адрес user@domain — логин и идентификатор ящика; alice@example.org и alice@example.net — разные пользователи.
Следующий готовый скрипт сохранить как /usr/local/sbin/mail-user (root:wheel, 0750). Он использует lockf, проверяет каталог, заменяет отдельные файлы атомарным rename и согласованно пересоздаёт recipient/sender maps. Алиас направляется только на существующего канонического пользователя того же домена и одновременно разрешает отправку с этого адреса. Удаление ящиков намеренно не автоматизировано.
#!/bin/sh
# mail-user: conservative file-catalog administration for Exim + Dovecot.
# Passwords are entered only into doveadm's terminal prompt.
# No delete operation, no automatic backups, no service startup.
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
export PATH
LC_ALL=C
export LC_ALL
umask 077
STATE=/usr/local/etc/virtual-mail
USERS=$STATE/users
EXIMDIR=/usr/local/etc/exim
DOMAINS=$EXIMDIR/domains
ALIASES=$EXIMDIR/aliases
MAILBOXES=$EXIMDIR/mailboxes
OWNERS=$EXIMDIR/sender_owners
MAILROOT=/opt/mail/vhosts
EXIM=/usr/local/sbin/exim
die() { printf '%s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Run with sudo."
[ -d "$STATE" ] && [ -d "$EXIMDIR" ] || die "Install the mail configuration directories first."
case "${1-}" in
--locked) shift ;;
*) exec /usr/bin/lockf -t 0 "$STATE/.mail-user.lock" "$0" --locked "$@" ;;
esac
[ "$#" -ge 1 ] || die "Usage: mail-user add-domain DOMAIN | add-user ADDRESS | set-password ADDRESS | add-alias ALIAS CANONICAL"
action=$1
shift
PASSWORD_HASH_FILE=
case "$action" in
add-domain) [ "$#" -eq 1 ] || die "add-domain takes one domain." ;;
add-user|set-password)
if [ "$#" -eq 3 ] && [ "$2" = --hash-file ]; then
PASSWORD_HASH_FILE=$3
elif [ "$#" -ne 1 ]; then
die "Use ADDRESS, optionally followed by --hash-file ROOT_ONLY_FILE."
fi
;;
add-alias) [ "$#" -eq 2 ] || die "add-alias takes ALIAS CANONICAL." ;;
*) die "Unknown operation." ;;
esac
/sbin/mount -p | awk '$2 == "/opt" && $3 == "ufs" { found = 1 } END { exit !found }' ||
die "/opt must be mounted as UFS before mail administration."
EXIMGROUP=$("$EXIM" -bP exim_group | awk -F ' = ' 'NR == 1 { print $2 }')
[ -n "$EXIMGROUP" ] || die "Cannot determine the Exim runtime group."
pw groupshow "$EXIMGROUP" >/dev/null || die "The Exim runtime group does not exist."
pw groupshow dovecot >/dev/null || die "The dovecot group does not exist."
VUID=$(id -u vmail)
VGID=$(id -g vmail)
for file in "$USERS" "$DOMAINS" "$ALIASES"; do
[ ! -L "$file" ] || die "Refusing a symlink in the mail catalog."
if [ ! -e "$file" ]; then
: > "$file"
case "$file" in
"$USERS") chown root:dovecot "$file" ;;
*) chown root:"$EXIMGROUP" "$file" ;;
esac
chmod 0640 "$file"
fi
[ -f "$file" ] || die "The mail catalog contains a non-regular file."
done
work=$(mktemp -d "$STATE/.mail-user.XXXXXX")
pending=
cleanup() {
[ -z "$pending" ] || rm -f "$pending"
rm -f "$work/users" "$work/domains" "$work/aliases" "$work/mailboxes" "$work/owners" "$work/user-keys" "$work/alias-records"
rmdir "$work" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
normalize() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
validate_domain() {
vd=$1
[ "${#vd}" -le 253 ] || die "Domain is too long."
case "$vd" in
''|.*|*.|*..*|*[!a-z0-9.-]*) die "Invalid domain: $vd" ;;
esac
oldifs=$IFS
IFS=.
set -- $vd
IFS=$oldifs
for label do
[ "${#label}" -ge 1 ] && [ "${#label}" -le 63 ] || die "Invalid domain label."
case "$label" in -*|*-) die "Invalid domain label." ;; esac
done
}
validate_address() {
va=$1
case "$va" in
*@*) ;;
*) die "Use a full email address." ;;
esac
vl=${va%%@*}
vd=${va#*@}
case "$vd" in *@*) die "Invalid email address." ;; esac
[ "${#vl}" -ge 1 ] && [ "${#vl}" -le 64 ] || die "Invalid local part length."
case "$vl" in
[a-z0-9]*) ;;
*) die "Local part must start with a letter or digit." ;;
esac
case "$vl" in *[!a-z0-9._+-]*|*.|*..*) die "Invalid local part." ;; esac
validate_domain "$vd"
}
has_domain() { grep -Fx "$1" "$DOMAINS" >/dev/null; }
has_user() { awk -F: -v key="$1" '$0 !~ /^[[:space:]]*(#|$)/ && $1 == key { found = 1 } END { exit !found }' "$USERS"; }
has_alias() { awk -F: -v key="$1" '$0 !~ /^[[:space:]]*(#|$)/ { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); if ($1 == key) found = 1 } END { exit !found }' "$ALIASES"; }
replace_file() {
source=$1
destination=$2
group=$3
pending=$(mktemp "$(dirname "$destination")/.mail-user-file.XXXXXX")
cat "$source" > "$pending"
chown root:"$group" "$pending"
chmod 0640 "$pending"
mv -f "$pending" "$destination"
pending=
}
# Validate the existing canonical catalog before deriving Exim recipient maps.
validate_catalog() {
awk -F: -v expected_uid="$VUID" -v expected_gid="$VGID" -v mailroot="$MAILROOT" '
/^[[:space:]]*(#|$)/ { next }
NF != 8 || $1 == "" || $2 !~ /^\{[A-Z0-9-]+\}/ || seen[$1]++ { exit 1 }
{
split($1, address, "@")
if ($3 != expected_uid || $4 != expected_gid || $6 != mailroot "/" address[2] "/" address[1]) exit 1
}
{ print $1 }
' "$USERS" > "$work/user-keys" || die "Malformed or duplicate canonical user record."
while IFS= read -r key; do
validate_address "$key"
[ "$(normalize "$key")" = "$key" ] || die "Canonical addresses must be lowercase."
has_domain "${key#*@}" || die "A canonical user's domain is missing from domains."
done < "$work/user-keys"
awk -F: '
/^[[:space:]]*(#|$)/ { next }
NF != 2 { exit 1 }
{
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
if ($1 == "" || $2 == "" || seen[$1]++) exit 1
print $1 ":" $2
}
' "$ALIASES" > "$work/alias-records" || die "Malformed or duplicate alias record."
while IFS=: read -r alias target; do
validate_address "$alias"
validate_address "$target"
[ "$(normalize "$alias")" = "$alias" ] && [ "$(normalize "$target")" = "$target" ] ||
die "Alias addresses must be lowercase."
[ "${alias#*@}" = "${target#*@}" ] || die "An alias crosses domains."
has_user "$target" || die "An alias target is not an existing canonical user."
if has_user "$alias"; then die "An alias conflicts with a canonical user."; fi
done < "$work/alias-records"
}
regenerate_maps() {
validate_catalog
awk '{ print $0 ": yes" }' "$work/user-keys" > "$work/mailboxes"
awk '{ print $0 ": " $0 }' "$work/user-keys" > "$work/owners"
awk -F: '{ print $1 ": " $2 }' "$work/alias-records" >> "$work/owners"
replace_file "$work/mailboxes" "$MAILBOXES" "$EXIMGROUP"
replace_file "$work/owners" "$OWNERS" "$EXIMGROUP"
}
new_password_hash() {
if [ -n "$PASSWORD_HASH_FILE" ]; then
case "$PASSWORD_HASH_FILE" in /*) ;; *) die "Hash file path must be absolute." ;; esac
[ -f "$PASSWORD_HASH_FILE" ] && [ ! -L "$PASSWORD_HASH_FILE" ] || die "Invalid hash file."
[ "$(stat -f %u "$PASSWORD_HASH_FILE")" -eq 0 ] || die "Hash file must belong to root."
case "$(stat -f %Lp "$PASSWORD_HASH_FILE")" in 400|600) ;; *) die "Hash file must have mode 0400 or 0600." ;; esac
NEW_HASH=$(cat "$PASSWORD_HASH_FILE")
else
[ -t 0 ] || die "Run interactively in PuTTY to enter the mailbox password."
DOVEADM=$(command -v doveadm) || die "doveadm is unavailable."
if "$DOVEADM" -O pw -l | tr ' ' '\n' | grep -Fx ARGON2ID >/dev/null; then
NEW_HASH=$("$DOVEADM" -O pw -s ARGON2ID)
else
NEW_HASH=$("$DOVEADM" -O pw -s BLF-CRYPT -r 12)
fi
fi
case "$NEW_HASH" in
\{ARGON2ID\}*|\{BLF-CRYPT\}*) ;;
*) die "doveadm did not return a supported password hash." ;;
esac
case "$NEW_HASH" in *:*|*'
'*) die "Invalid password hash format." ;; esac
}
validate_catalog
case "$action" in
add-domain)
domain=$(normalize "$1")
validate_domain "$domain"
if has_domain "$domain"; then die "Domain already exists."; fi
cat "$DOMAINS" > "$work/domains"
printf '%s\n' "$domain" >> "$work/domains"
replace_file "$work/domains" "$DOMAINS" "$EXIMGROUP"
regenerate_maps
printf 'Domain added: %s\n' "$domain"
;;
add-user)
address=$(normalize "$1")
validate_address "$address"
domain=${address#*@}
localpart=${address%%@*}
has_domain "$domain" || die "Add the domain first."
if has_user "$address" || has_alias "$address"; then die "Address already exists."; fi
new_password_hash
home=$MAILROOT/$domain/$localpart
install -d -o root -g vmail -m 0750 "$MAILROOT" "$MAILROOT/$domain"
install -d -o vmail -g vmail -m 0750 "$home" "$home/Maildir" "$home/Maildir/cur" "$home/Maildir/new" "$home/Maildir/tmp"
cat "$USERS" > "$work/users"
printf '%s:%s:%s:%s::%s::\n' "$address" "$NEW_HASH" "$VUID" "$VGID" "$home" >> "$work/users"
replace_file "$work/users" "$USERS" dovecot
unset NEW_HASH
regenerate_maps
printf 'Mailbox added: %s\n' "$address"
;;
set-password)
address=$(normalize "$1")
validate_address "$address"
has_user "$address" || die "No such canonical user."
new_password_hash
# awk receives the hash through a root-only temporary input file,
# never through a command-line argument or an environment variable.
printf '%s\n' "$NEW_HASH" > "$work/users"
unset NEW_HASH
awk -F: -v OFS=: -v key="$address" '
NR == FNR { password = $0; next }
$1 == key { $2 = password }
{ print }
' "$work/users" "$USERS" > "$work/domains"
replace_file "$work/domains" "$USERS" dovecot
regenerate_maps
printf 'Password changed: %s\n' "$address"
;;
add-alias)
alias=$(normalize "$1")
target=$(normalize "$2")
validate_address "$alias"
validate_address "$target"
[ "${alias#*@}" = "${target#*@}" ] || die "Alias and canonical user must be in the same domain."
has_user "$target" || die "Target must be an existing canonical user."
if has_user "$alias" || has_alias "$alias"; then die "Alias address already exists."; fi
cat "$ALIASES" > "$work/aliases"
printf '%s: %s\n' "$alias" "$target" >> "$work/aliases"
replace_file "$work/aliases" "$ALIASES" "$EXIMGROUP"
regenerate_maps
printf 'Alias added: %s -> %s (including permission to send)\n' "$(normalize "$1")" "$(normalize "$2")"
;;
esac
# Dovecot's default authentication cache is disabled. If cache is enabled
# later, flush it after password changes; do not start services here.
exit 0
chown root:wheel /usr/local/sbin/mail-user
chmod 0750 /usr/local/sbin/mail-user
/usr/local/sbin/exim -bV
mail-user add-domain example.org
mail-user add-domain example.net
mail-user add-user alice@example.org
mail-user add-user alice@example.net
for domain in example.org example.net; do
for alias in root webmaster admin postmaster; do
mail-user add-alias "$alias@$domain" "alice@$domain"
done
done
Каждый add-user запрашивает новый пароль дважды. Во время первоначального наполнения службы ещё выключены. Для последующего обслуживания:
mail-user set-password alice@example.org mail-user add-alias support@example.org alice@example.org mail-user add-user bob@example.org
Алиасы не получают собственного пароля и не используются как независимые IMAP-логины. При стандартном отключённом auth cache смена пароля действует для новых входов без рестарта Dovecot. Уже открытая IMAP-сессия при этом не закрывается.
8. DKIM и DNS до запуска
Для каждого домена создать отдельный RSA-2048 ключ с селектором mail. Команда отказывается перезаписывать существующий ключ. Закрытые ключи не копируются в wiki и DNS.
umask 077
for domain in example.org example.net; do
key=/usr/local/etc/exim/dkim/$domain.key
test ! -e "$key" || { echo "Key already exists: $domain" >&2; exit 1; }
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$key"
chown root:mail "$key"
chmod 0640 "$key"
echo "DNS owner: mail._domainkey.$domain"
openssl pkey -in "$key" -pubout -outform DER | openssl base64 -A
printf '\n'
done
Опубликовать по одному TXT mail._domainkey в каждой зоне со значением v=DKIM1; k=rsa; p= и соответствующим полученным base64 без пробелов и переносов. В интерфейсе DNS вводится одно TXT-значение; в BIND длинная строка делится на строки не более 255 байт внутри одной записи. Селектор Exim уже настроен на mail.
Начальные примеры для новой инфраструктуры:
mail.example.org. 300 IN A 192.0.2.10 example.org. 300 IN TXT "v=spf1 ip4:192.0.2.10 ~all" example.net. 300 IN TXT "v=spf1 ip4:192.0.2.10 ~all" _dmarc.example.org. 300 IN TXT "v=DMARC1; p=none" _dmarc.example.net. 300 IN TXT "v=DMARC1; p=none"
Если SPF уже существует, объединить разрешённые источники в одной записи; второй SPF не добавлять. DMARC ужесточают после проверки всех легитимных источников. Для уже работающего домена не заменять его текущую политику этими начальными примерами.
MX публикуется в финале после проверки службы:
example.org. 300 IN MX 10 mail.example.org. example.net. 300 IN MX 10 mail.example.org.
9. Проверки до сертификатов
/usr/local/sbin/exim -bV /usr/local/sbin/exim -bP primary_hostname spool_directory exim_user exim_group /usr/local/bin/doveconf -o ssl=no -o ssl_server_cert_file= -o ssl_server_key_file= -n /usr/local/sbin/exim -bt alice@example.org /usr/local/sbin/exim -bt admin@example.net /usr/local/sbin/exim -bt does-not-exist@example.org mount -p | awk '$2 == "/opt"'
Последняя маршрутизация должна завершиться отказом для неизвестного пользователя. ssl=no выше действует только на процесс проверки doveconf и не изменяет рабочий конфиг. Пустые пути сертификатов используются лишь для этой проверки до выпуска. Почтовые службы всё ещё выключены.
10. Последний этап базовой установки: сертификат, TLS и запуск
Сначала убедиться, что A mail.example.org существует в публичном DNS. Ручной DNS-01 не требует входящего TCP 80. Если домен имеет CAA, он должен разрешать выбранный центр сертификации. Самоподписанный сертификат для обычных почтовых клиентов здесь не используется.
10.1. Hook проверки и активации
Сохранить следующий скрипт как /usr/local/sbin/mail-activate-tls, root:wheel, 0750. Он проверяет UFS, права, цепочку доверия, имя, срок и соответствие ключа; лишь затем включает и запускает/перечитывает Dovecot и Exim. Если включен Nginx, проверяется и перечитывается он. На первом запуске до появления сертификата hook должен завершаться отказом.
#!/bin/sh
# Activate or renew trusted TLS for mail.example.org.
# Suitable as an ACME reload hook. It never creates certificates or keys.
# All checks finish before boot settings or running daemons are changed.
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
export PATH
LC_ALL=C
export LC_ALL
umask 077
HOST=mail.example.org
CERTDIR=/usr/local/etc/mail-certs
CERT=$CERTDIR/fullchain.pem
KEY=$CERTDIR/privkey.pem
CAFILE=/etc/ssl/cert.pem
EXIM=/usr/local/sbin/exim
DOVECONF=/usr/local/bin/doveconf
OPENSSL=/usr/bin/openssl
SERVICE=/usr/sbin/service
die() { printf 'mail-activate-tls: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as the root ACME hook."
case "${1-}" in
--locked) [ "$#" -eq 1 ] || die "No arguments are supported."; shift ;;
'') exec /usr/bin/lockf -t 0 /var/run/mail-activate-tls.lock "$0" --locked ;;
*) die "No arguments are supported." ;;
esac
# Fail closed if the mail filesystem is unavailable or read-only.
/sbin/mount -p | awk '
$2 == "/opt" && $3 == "ufs" && $4 !~ /(^|,)ro(,|$)/ { found = 1 }
END { exit !found }
' || die "/opt is not mounted as a writable UFS filesystem."
[ -d "$CERTDIR" ] || die "Certificate directory is missing."
[ -f "$CERT" ] && [ -r "$CERT" ] && [ -s "$CERT" ] ||
die "Trusted fullchain.pem is missing, empty or unreadable; services were not changed."
[ -f "$KEY" ] && [ -r "$KEY" ] && [ -s "$KEY" ] ||
die "privkey.pem is missing, empty or unreadable; services were not changed."
[ ! -L "$CERT" ] && [ ! -L "$KEY" ] ||
die "Install the certificate and key as real files in mail-certs, not symlinks into a private ACME directory."
[ -f "$CAFILE" ] && [ -r "$CAFILE" ] && [ -s "$CAFILE" ] ||
die "System CA bundle /etc/ssl/cert.pem is unavailable."
mail_gid=$(pw groupshow mail | awk -F: 'NR == 1 { print $3 }')
[ -n "$mail_gid" ] || die "Cannot determine the mail group."
# The fixed deployment directory is readable by the configured Exim group.
[ "$(stat -L -f '%u:%g:%Lp' "$KEY")" = "0:$mail_gid:640" ] ||
die "The private key must be owned by root:mail with mode 0640."
[ "$(stat -L -f '%u:%g:%Lp' "$CERTDIR")" = "0:$mail_gid:750" ] ||
die "The certificate directory must be root:mail with mode 0750."
[ "$(stat -L -f %u "$CERT")" -eq 0 ] ||
die "The certificate must belong to root."
case "$(stat -L -f %Lp "$CERT")" in
640|440)
[ "$(stat -L -f %g "$CERT")" = "$mail_gid" ] ||
die "A group-readable-only certificate must belong to the mail group."
;;
644|444) ;;
*) die "The certificate must be readable and not writable by group or others." ;;
esac
[ -x "$OPENSSL" ] && [ -x "$EXIM" ] && [ -x "$DOVECONF" ] ||
die "OpenSSL, Exim or doveconf is unavailable."
work=$(mktemp -d /var/run/mail-tls-check.XXXXXX)
cleanup() {
rm -f "$work/chain.p7b" "$work/cert.pub.pem" "$work/cert.pub.der" "$work/key.pub.der"
rmdir "$work" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
# Parse every certificate in the supplied PEM chain.
"$OPENSSL" crl2pkcs7 -nocrl -certfile "$CERT" -outform DER -out "$work/chain.p7b" >/dev/null 2>&1 ||
die "The supplied fullchain.pem is not a valid PEM certificate chain."
"$OPENSSL" x509 -in "$CERT" -noout -checkhost "$HOST" >/dev/null 2>&1 ||
die "The certificate does not match mail.example.org."
"$OPENSSL" x509 -in "$CERT" -noout -checkend 0 >/dev/null 2>&1 ||
die "The certificate is expired."
# Also checks notBefore, issuer chain, trust, server purpose and hostname.
# A self-signed or untrusted certificate never triggers service activation.
"$OPENSSL" verify -CAfile "$CAFILE" -purpose sslserver -verify_hostname "$HOST" -untrusted "$CERT" "$CERT" >/dev/null 2>&1 ||
die "Certificate verification failed: check system trust, full chain, dates and server name."
# Empty passphrase prevents an unattended hook from blocking on a prompt.
# Nothing from the private key is printed or written into temporary files.
"$OPENSSL" pkey -in "$KEY" -passin pass: -check -noout >/dev/null 2>&1 ||
die "The private key is invalid, encrypted or unreadable."
"$OPENSSL" x509 -in "$CERT" -pubkey -noout > "$work/cert.pub.pem" 2>/dev/null ||
die "Cannot read the certificate public key."
"$OPENSSL" pkey -pubin -in "$work/cert.pub.pem" -outform DER -out "$work/cert.pub.der" >/dev/null 2>&1 ||
die "Cannot normalize the certificate public key."
"$OPENSSL" pkey -in "$KEY" -passin pass: -pubout -outform DER -out "$work/key.pub.der" >/dev/null 2>&1 ||
die "Cannot derive the private key's public component."
cmp -s "$work/cert.pub.der" "$work/key.pub.der" ||
die "The certificate and private key do not match."
# Read the real installed production configurations with real certificates.
"$EXIM" -bV >/dev/null || die "Exim configuration check failed."
"$DOVECONF" -n >/dev/null || die "Dovecot configuration check failed."
# SnappyMail shares this certificate through nginx's root master process.
# Check nginx before reloading any certificate consumer when webmail is enabled.
reload_nginx=no
if [ -x /usr/local/sbin/nginx ] &&
[ "$(/usr/sbin/sysrc -n nginx_enable 2>/dev/null || true)" = YES ]; then
/usr/local/sbin/nginx -t || die "nginx configuration check failed."
reload_nginx=yes
fi
# No daemon, rc setting, certificate or key was changed before this point.
/usr/sbin/sysrc dovecot_enable=YES exim_enable=YES >/dev/null ||
die "Could not enable the two mail services at boot."
start_or_reload() {
srv=$1
if "$SERVICE" "$srv" onestatus >/dev/null 2>&1; then
"$SERVICE" "$srv" onereload ||
die "$srv reload failed; no forced restart was attempted."
else
"$SERVICE" "$srv" onestart ||
die "$srv start failed."
fi
}
# Dovecot owns both the SMTP AUTH and LMTP sockets, so it goes first.
start_or_reload dovecot
ready=no
for attempt in 1 2 3 4 5; do
if "$SERVICE" dovecot onestatus >/dev/null 2>&1 &&
[ -S /var/run/dovecot/auth-exim ] &&
[ -S /var/run/dovecot/lmtp-exim ]; then
ready=yes
break
fi
sleep 1
done
[ "$ready" = yes ] || die "Dovecot did not become ready with both Exim sockets; Exim was not changed."
start_or_reload exim
ready=no
for attempt in 1 2 3 4 5; do
if "$SERVICE" exim onestatus >/dev/null 2>&1; then
ready=yes
break
fi
sleep 1
done
[ "$ready" = yes ] || die "Exim did not become ready."
if [ "$reload_nginx" = yes ]; then
start_or_reload nginx
fi
printf 'Trusted TLS activated or reloaded for %s; Dovecot and Exim are running.\n' "$HOST"
if [ "$reload_nginx" = yes ]; then
printf 'SnappyMail nginx also reloaded with the same certificate.\n'
fi
chmod 0750 /usr/local/sbin/mail-activate-tls chown root:wheel /usr/local/sbin/mail-activate-tls
10.2. Ручной выпуск DNS-01
Выбрать свой реальный административный email. ACME хранится в отдельном root-only каталоге. Команды ниже вводятся на сервере от root; выданный TXT берется из фактического ответа acme.sh, а не из чужой инструкции.
install -d -o root -g wheel -m 0700 /usr/local/etc/virtual-mail/acme acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --register-account --server letsencrypt -m postmaster@example.org acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --issue --server letsencrypt --dns -d mail.example.org --keylength ec-256 --yes-I-know-dns-manual-mode-enough-go-ahead-please
Создать TXT с именем _acme-challenge.mail.example.org и значением, показанным acme.sh. Дождаться ответа авторитетных DNS, затем подтвердить проверку:
acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --renew -d mail.example.org --ecc --yes-I-know-dns-manual-mode-enough-go-ahead-please
После успешного выпуска установить копии сертификата в рабочий каталог. Hook выставляет права до строгой проверки:
acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --install-cert -d mail.example.org --ecc --fullchain-file /usr/local/etc/mail-certs/fullchain.pem --key-file /usr/local/etc/mail-certs/privkey.pem --reloadcmd 'chown root:mail /usr/local/etc/mail-certs/fullchain.pem /usr/local/etc/mail-certs/privkey.pem && chmod 0640 /usr/local/etc/mail-certs/fullchain.pem /usr/local/etc/mail-certs/privkey.pem && /usr/local/sbin/mail-activate-tls'
Ручной DNS-01 не обеспечивает автоматическое продление. До истечения срока повторить процедуру, опубликовав новое значение TXT; после успешного продления снова выполнить install-cert. Для автоматизации впоследствии перейти на DNS API с минимально необходимыми правами. Один сертификат mail.example.org используется для всех ящиков; почтовые клиенты всех доменов подключаются именно к этому имени.
10.3. Проверка клиента и переключение MX
service dovecot status service exim status sockstat -46l openssl s_client -connect mail.example.org:993 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem openssl s_client -connect mail.example.org:465 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem openssl s_client -starttls smtp -connect mail.example.org:587 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem exim -bpc
После просмотра TLS нажать Ctrl-D. Проверить реальный вход alice@example.org и alice@example.net: IMAP mail.example.org:993 SSL/TLS; SMTP mail.example.org:465 SSL/TLS либо 587 STARTTLS, обязательный AUTH и полный логин. Неверный пароль и relay без AUTH должны отклоняться. Проверить доставку на канонический адрес и алиас, отсутствие неизвестных получателей; затем опубликовать MX из раздела DNS. Проверка из LAN через публичный IP может использовать NAT reflection: внешнюю доступность отдельно проверяют из независимой сети.
Для локальной системной почты после проверки настроить /etc/mail/mailer.conf:
sendmail /usr/local/sbin/exim send-mail /usr/local/sbin/exim mailq /usr/local/sbin/exim newaliases /usr/local/sbin/exim hoststat /usr/local/sbin/exim purgestat /usr/local/sbin/exim
Локальный sendmail-совместимый вызов и SMTP AUTH — разные пути. Приведенные проверки отправителя в ACL относятся к SMTP.
11. Необязательно: SnappyMail на этом же сервере
Этот этап выполняется после запуска почты с доверенным TLS. Nginx обслуживает веб-клиент, отдельный PHP-FPM pool работает от snappymail. Пароли почты и Maildir веб-процессу не передаются как файлы: он использует IMAPS/SMTP AUTH вошедшего пользователя.
11.1. Пакеты и каталоги
pkg install -n snappymail-php84 nginx php84-opcache pkg install -y snappymail-php84 nginx php84-opcache sysrc nginx_enable=NO php_fpm_enable=NO pw groupadd snappymail pw useradd snappymail -g snappymail -d /nonexistent -s /usr/sbin/nologin -w no install -d -o root -g wheel -m 0755 /opt/webmail /opt/webmail/nginx /opt/webmail/snappymail install -d -o www -g www -m 0700 /opt/webmail/nginx/client_body /opt/webmail/nginx/fastcgi install -d -o root -g wheel -m 0750 /opt/webmail/nginx/log install -d -o snappymail -g snappymail -m 0700 /opt/webmail/snappymail/data /opt/webmail/snappymail/tmp /opt/webmail/snappymail/sessions install -d -o root -g snappymail -m 0750 /opt/webmail/snappymail/log install -o snappymail -g snappymail -m 0600 /dev/null /opt/webmail/snappymail/log/php-error.log
Команды создания пользователя и пустого журнала относятся к первой установке. При повторном выполнении проверять существующие записи и не обнулять журналы.
Сохранить /usr/local/www/snappymail/include.php как root:wheel 0644:
<?php
define('APP_DATA_FOLDER_PATH', '/opt/webmail/snappymail/data/');
Код /usr/local/www/snappymail остается root-owned. Не делать его доступным на запись PHP-пользователю.
В /etc/hosts добавить единственное разрешение имени mail.example.org в 127.0.0.1, предварительно исправив конфликтующие записи этого имени:
127.0.0.1 mail.example.org
Это сохраняет локальный маршрут и проверку TLS по FQDN.
11.2. PHP-FPM и PHP
Установить php.ini-production как /usr/local/etc/php.ini, если своего файла ещё нет. Сохранить /usr/local/etc/php-fpm.conf (все конфиги PHP/Nginx ниже — root:wheel 0644):
[global] pid = /var/run/php-fpm.pid error_log = /opt/webmail/snappymail/log/php-fpm-master.log log_level = notice include = /usr/local/etc/php-fpm.d/snappymail.conf
Полный pool /usr/local/etc/php-fpm.d/snappymail.conf:
[snappymail] user = snappymail group = snappymail listen = /var/run/snappymail-php-fpm.sock listen.owner = www listen.group = www listen.mode = 0660 pm = ondemand pm.max_children = 4 pm.process_idle_timeout = 30s pm.max_requests = 500 chdir = /usr/local/www/snappymail clear_env = yes catch_workers_output = yes security.limit_extensions = .php env[TMPDIR] = /opt/webmail/snappymail/tmp php_admin_value[upload_tmp_dir] = /opt/webmail/snappymail/tmp php_admin_value[sys_temp_dir] = /opt/webmail/snappymail/tmp php_admin_value[session.save_path] = /opt/webmail/snappymail/sessions php_admin_value[error_log] = /opt/webmail/snappymail/log/php-error.log php_admin_flag[log_errors] = on php_admin_flag[display_errors] = off
Сохранить /usr/local/etc/php/99-snappymail.ini:
date.timezone = Europe/Moscow expose_php = Off display_errors = Off log_errors = On memory_limit = 128M max_execution_time = 90 post_max_size = 32M upload_max_filesize = 25M session.cookie_secure = 1 session.cookie_httponly = 1 session.cookie_samesite = Lax cgi.fix_pathinfo = 0 openssl.cafile = /etc/ssl/cert.pem curl.cainfo = /etc/ssl/cert.pem opcache.enable = 1 opcache.memory_consumption = 64 opcache.interned_strings_buffer = 8 opcache.max_accelerated_files = 20000 opcache.validate_timestamps = 1 opcache.revalidate_freq = 60
В /etc/rc.conf.d/php_fpm:
required_dirs="/opt/webmail/snappymail/data /opt/webmail/snappymail/tmp /opt/webmail/snappymail/sessions"
11.3. Инициализация приложения без публикации пароля
Сохранить следующий одноразовый PHP-скрипт в /root/bootstrap-webmail.php (root:wheel, 0600). Он предназначен для новой установки и отказывается заменять существующую конфигурацию. Запустить интерактивно с отключенным эхом терминала; задать новый длинный уникальный пароль администратора дважды. Сам пароль в файл или аргументы не записывается.
<?php
declare(strict_types=1);
umask(0077);
$base = '/opt/webmail/snappymail/data/_data_/_default_';
if (is_file($base.'/configs/application.ini')) {
throw new RuntimeException('Already configured; do not reset it.');
}
fwrite(STDERR, "New SnappyMail administrator password: ");
$first = rtrim((string)fgets(STDIN), "\r\n");
fwrite(STDERR, "\nRepeat password: ");
$second = rtrim((string)fgets(STDIN), "\r\n");
fwrite(STDERR, "\n");
if (strlen($first) < 20 || !hash_equals($first, $second)) {
throw new RuntimeException('Passwords differ or are too short.');
}
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = 1;
require '/usr/local/www/snappymail/index.php';
$config = \RainLoop\Api::Config();
$config->Set('webmail', 'title', 'Webmail');
$config->Set('webmail', 'language', 'ru');
$config->Set('webmail', 'attachment_size_limit', 25);
$config->Set('security', 'allow_admin_panel', true);
$config->Set('security', 'admin_login', 'admin');
$config->Set('security', 'force_https', true);
$config->Set('admin_panel', 'allow_update', false);
$config->Set('ssl', 'verify_certificate', true);
$config->Set('ssl', 'allow_self_signed', false);
$config->Set('ssl', 'cafile', '/etc/ssl/cert.pem');
$config->Set('contacts', 'enable', true);
$config->Set('contacts', 'type', 'sqlite');
$config->Set('contacts', 'sqlite_global', false);
$config->SetPassword(new \SnappyMail\SensitiveString($first));
if (!$config->Save()) { throw new RuntimeException('Save failed.'); }
unset($first, $second);
$ssl = ['verify_peer'=>true, 'verify_peer_name'=>true,
'allow_self_signed'=>false, 'cafile'=>'/etc/ssl/cert.pem'];
$settings = [
'IMAP' => ['host'=>'mail.example.org','port'=>993,'type'=>1,
'shortLogin'=>false,'lowerLogin'=>true,'ssl'=>$ssl],
'SMTP' => ['host'=>'mail.example.org','port'=>465,'type'=>1,
'shortLogin'=>false,'lowerLogin'=>true,'useAuth'=>true,
'setSender'=>false,'usePhpMail'=>false,'authPlainLine'=>false,'ssl'=>$ssl],
'Sieve' => ['host'=>'mail.example.org','port'=>4190,'type'=>1,
'enabled'=>false,'shortLogin'=>false],
'whiteList' => ''
];
$dir = $base.'/domains';
if (!is_dir($dir)) { mkdir($dir, 0700, true); }
foreach (['default.json','gmail.com.json','hotmail.com.json','mail.example.org.json','disabled'] as $sample) {
if (is_file($dir.'/'.$sample)) { unlink($dir.'/'.$sample); }
}
foreach (['example.org','example.net'] as $domain) {
\RainLoop\Model\Domain::fromArray($domain, $settings);
file_put_contents($dir.'/'.$domain.'.json',
json_encode($settings, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n");
chmod($dir.'/'.$domain.'.json', 0600);
}
if (is_file($base.'/admin_password.txt')) { unlink($base.'/admin_password.txt'); }
echo "Configured.\n";
Выполнить в интерактивном /bin/sh:
php -l /root/bootstrap-webmail.php
stty -echo
trap 'stty echo' EXIT HUP INT TERM
php /root/bootstrap-webmail.php
result=$?
stty echo
trap - EXIT HUP INT TERM
test "$result" -eq 0 || exit "$result"
chown -R snappymail:snappymail /opt/webmail/snappymail/data
find /opt/webmail/snappymail/data -type d -exec chmod 0700 {} +
find /opt/webmail/snappymail/data -type f -exec chmod 0600 {} +
rm /root/bootstrap-webmail.php
При ошибке не запускать веб-сервер до исправления и проверки настроек администратора. Панель после запуска — https://mail.example.org/?admin, логин admin, заданный вами пароль. В FreeBSD регистр имени API важен: используется RainLoop\Api, не RainLoop\API.
11.4. Nginx и запуск
Полный минимальный /usr/local/etc/nginx/nginx.conf для сервера, на котором еще нет других сайтов:
user www;
worker_processes auto;
pid /var/run/nginx.pid;
error_log /opt/webmail/nginx/log/error.log warn;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
server_tokens off;
access_log /opt/webmail/nginx/log/access.log;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
client_body_temp_path /opt/webmail/nginx/client_body;
fastcgi_temp_path /opt/webmail/nginx/fastcgi;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
server {
listen 127.0.0.1:80;
listen 198.51.100.20:80;
server_name mail.example.org;
return 308 https://mail.example.org$request_uri;
}
server {
listen 127.0.0.1:443 ssl;
listen 198.51.100.20:443 ssl;
server_name mail.example.org;
ssl_certificate /usr/local/etc/mail-certs/fullchain.pem;
ssl_certificate_key /usr/local/etc/mail-certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:4m;
ssl_session_timeout 1h;
ssl_session_tickets off;
root /usr/local/www/snappymail;
index index.php;
client_max_body_size 32m;
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy no-referrer always;
location = /data { return 404; }
location ^~ /data/ { return 404; }
location = /include.php { return 404; }
location = /_include.php { return 404; }
location ~ (^|/)\. { return 404; }
location ~ ^/snappymail/v/[^/]+/(app|vendor)/ { return 404; }
location = /webmail { return 302 /; }
location = /webmail/ { return 302 /; }
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/var/run/snappymail-php-fpm.sock;
fastcgi_hide_header X-Powered-By;
fastcgi_read_timeout 90s;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
}
location ~* \.php(?:/|$) { return 404; }
}
}
Если сайты уже есть, добавить server-блоки в существующий http, согласовав глобальные параметры. В /etc/rc.conf.d/nginx:
required_dirs="/opt/webmail/nginx/client_body /opt/webmail/nginx/log"
Проверить и включить службы:
php-fpm -t nginx -t sysrc php_fpm_enable=YES nginx_enable=YES service php_fpm start service nginx start fetch -o /dev/null https://mail.example.org/
Проверить в браузере вход полным адресом, список папок, отправку самому себе и наличие письма в INBOX/Sent. Файлы данных, include.php и произвольные .php должны возвращать 404. При добавлении почтового домена через mail-user также добавить его в SnappyMail через админ-панель с теми же параметрами IMAP/SMTP и полной формой логина.
11.5. Если публичный 443 занимает другой Nginx
На внешнем Nginx добавить следующие server-блоки внутри http. Использовать доверенный сертификат mail.example.org; приватный ключ на frontend принадлежит root, режим 0600. Сертификат нужно обновлять на обоих серверах при продлении. HTTPS от frontend до backend тоже проверяется:
# Add these server blocks inside the FRONT nginx http {} context.
# Copy the existing mail.example.org certificate from the MAIL server:
# /usr/local/etc/mail-certs/fullchain.pem -> /usr/local/etc/nginx/ssl/mail.example.org/fullchain.pem
# /usr/local/etc/mail-certs/privkey.pem -> /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem
# Keep the private key root-owned, mode 0600, on the front server.
# The front nginx must be able to reach 198.51.100.20 TCP 443.
server {
listen 80;
server_name mail.example.org;
return 308 https://mail.example.org$request_uri;
}
server {
listen 443 ssl;
server_name mail.example.org;
ssl_certificate /usr/local/etc/nginx/ssl/mail.example.org/fullchain.pem;
ssl_certificate_key /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 32m;
location / {
proxy_pass https://198.51.100.20;
proxy_http_version 1.1;
proxy_set_header Host mail.example.org;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_ssl_name mail.example.org;
proxy_ssl_verify on;
# FreeBSD system CA bundle; on Debian/Ubuntu use
# /etc/ssl/certs/ca-certificates.crt if /etc/ssl/cert.pem is absent.
proxy_ssl_trusted_certificate /etc/ssl/cert.pem;
proxy_ssl_verify_depth 3;
proxy_connect_timeout 10s;
proxy_read_timeout 90s;
proxy_send_timeout 90s;
proxy_buffer_size 32k;
proxy_buffers 16 16k;
}
}
Перед reload выполнить nginx -t. В этой схеме NAT 443 указывает на frontend, а он должен достигать 198.51.100.20:443 по LAN. Порты SMTP/IMAP идут непосредственно на почтовый сервер.
12. Необязательно: ClamAV и проверка SMTP-писем
Антивирус подключается после проверки базовой доставки. Exim передает содержимое clamd потоком по loopback; сканеру не нужен доступ к Maildir, очереди и закрытым ключам.
pkg install -n clamav pkg install -y clamav sysrc clamav_clamd_enable=NO clamav_freshclam_enable=NO # Пакет может включить clamav в дополнительную группу mail. # Для потоковой проверки это не нужно: case " $(id -Gn clamav) " in *' mail '*) pw groupmod mail -d clamav ;; esac install -d -o root -g clamav -m 0750 /opt/clamav /opt/clamav/log install -d -o clamav -g clamav -m 0750 /opt/clamav/db /var/run/clamav install -d -o clamav -g clamav -m 0700 /opt/clamav/tmp install -o clamav -g clamav -m 0640 /dev/null /opt/clamav/log/clamd.log install -o clamav -g clamav -m 0640 /dev/null /opt/clamav/log/freshclam.log
Штатные rc-скрипты FreeBSD проверяют базы в /var/db/clamav. Для новой установки пустой каталог можно заменить ссылкой; существующие базы не удалять:
if [ -L /var/db/clamav ]; then
test "$(readlink /var/db/clamav)" = /opt/clamav/db || exit 1
elif [ -d /var/db/clamav ]; then
rmdir /var/db/clamav || exit 1
ln -s /opt/clamav/db /var/db/clamav
elif [ ! -e /var/db/clamav ]; then
ln -s /opt/clamav/db /var/db/clamav
else
echo 'Unexpected database path' >&2
exit 1
fi
chown -h root:wheel /var/db/clamav
/usr/local/etc/clamd.conf:
LogFile /opt/clamav/log/clamd.log LogFileMaxSize 0 LogTime yes LogRotate no LogSyslog yes LogFacility LOG_DAEMON PidFile /var/run/clamav/clamd.pid DatabaseDirectory /opt/clamav/db TemporaryDirectory /opt/clamav/tmp User clamav TCPSocket 3310 TCPAddr 127.0.0.1 EnableShutdownCommand no MaxThreads 4 MaxConnectionQueueLength 30 ReadTimeout 120 CommandReadTimeout 30 StreamMaxLength 64M MaxFileSize 64M MaxScanSize 256M MaxScanTime 45000 MaxRecursion 17 MaxFiles 10000 AlertExceedsMax yes ScanMail yes ScanArchive yes SelfCheck 3600 ConcurrentDatabaseReload yes
/usr/local/etc/freshclam.conf:
DatabaseDirectory /opt/clamav/db DatabaseOwner clamav UpdateLogFile /opt/clamav/log/freshclam.log LogFileMaxSize 0 LogTime yes LogRotate no LogSyslog yes LogFacility LOG_DAEMON PidFile /var/run/clamav/freshclam.pid DatabaseMirror database.clamav.net ScriptedUpdates yes Checks 12 ConnectTimeout 30 ReceiveTimeout 90 TestDatabases yes NotifyClamd /usr/local/etc/clamd.conf
В /etc/rc.conf.d/clamav_clamd и /etc/rc.conf.d/clamav_freshclam добавить по строке:
required_dirs="/opt/clamav/db /opt/clamav/tmp /opt/clamav/log"
Первую загрузку баз выполнить до запуска clamd:
freshclam --config-file=/usr/local/etc/freshclam.conf sigtool --info /opt/clamav/db/main.cvd sigtool --info /opt/clamav/db/daily.cvd sigtool --info /opt/clamav/db/bytecode.cvd sysrc clamav_clamd_enable=YES clamav_freshclam_enable=YES service clamav_clamd start service clamav_freshclam start service clamav_clamd status sockstat -4l | grep 3310
При первом bootstrap сообщение NotifyClamd о еще не запущенном clamd допустимо только после успешной загрузки и проверки баз. До подключения Exim clamd должен работать. Checks 12 означает проверку обновлений 12 раз в сутки. Если официальный CDN отвечает 403/429, не повторять запросы в цикле: разобраться с ограничением или использовать подходящее HTTPS-зеркало официальных подписанных баз. Например DatabaseMirror https://mirror.truenetwork.ru/clamav и ScriptedUpdates no загружают полные CVD; сохраняются DNS-проверка актуальности и проверка подписи. Доступность зеркала и свежесть баз проверяются в момент установки.
В главной части конфигурации Exim, до begin acl, добавить:
av_scanner = clamd:127.0.0.1 3310
В acl_check_data после существующих проверок From и перед завершающим accept добавить:
deny
malware = * / tmo=60s
message = Message rejected by antivirus policy ($malware_name)
log_message = ClamAV detected $malware_name
Затем:
exim -bV service exim reload exim -bP av_scanner tail -20 /opt/clamav/log/freshclam.log
Обнаруженное содержимое отклоняется после DATA с 5xx, включая AUTH-порты 465/587 и SnappyMail. Без defer_ok сбой сканера дает временный 451. AlertExceedsMax yes делает превышение лимитов видимым как Heuristics.Limits.Exceeded. Существующая локальная отправка через sendmail-совместимый вызов не проходит SMTP DATA ACL; ее отдельно учитывают при расширении политики.
Проверить контролируемым письмом обычную доставку, затем стандартным тестом EICAR и ZIP с ним: вредоносное тестовое вложение не должно попасть в ящик. Использовать отдельный тестовый ящик и удалить только его тестовые данные. Сам EICAR брать с официального сайта; не включать его тело в общие журналы и документацию.
12.1. Ротация журналов
Установить newsyslog-правила, чтобы журналы не заполнили UFS. Для Exim пример /usr/local/etc/newsyslog.conf.d/virtual-mail.conf:
/var/log/exim/mainlog mailnull:mail 0640 7 1024 * JC /var/log/exim/rejectlog mailnull:mail 0640 7 1024 * JC /var/log/exim/paniclog mailnull:mail 0640 7 1024 * JC /opt/clamav/log/clamd.log clamav:clamav 0640 7 1024 * JC /var/run/clamav/clamd.pid 1 /opt/clamav/log/freshclam.log clamav:clamav 0640 7 1024 * JC /var/run/clamav/freshclam.pid 1
Проверить newsyslog -n -f /usr/local/etc/newsyslog.conf.d/virtual-mail.conf. Правила Exim относятся и к базовой установке без ClamAV; строки ClamAV добавлять только после его установки. Веб-журналы также включить в newsyslog: nginx требует сигнал USR1 для открытия новых файлов, PHP-FPM — штатный сигнал переоткрытия своих журналов. После пакетного обновления проверять владельцев, режимы и членство clamav в группах.
13. Необязательно: Grafana Alloy
Alloy устанавливается отдельно при наличии центральных Loki/Prometheus и их CA. Не выставлять его API на публичный интерфейс. Стандартный набор: /var/log/auth.log, /var/log/messages, CPU/RAM, версии kernel/userland. Состояние и WAL разместить в /opt/alloy, конфигурацию — в /usr/local/etc/alloy.flow, запуск от отдельного пользователя alloy.
pkg install alloy pw groupshow alloy >/dev/null 2>&1 || pw groupadd alloy pw usershow alloy >/dev/null 2>&1 || pw useradd alloy -g alloy -d /opt/alloy -s /usr/sbin/nologin -w no install -d -o alloy -g alloy -m 0750 /opt/alloy sysrc alloy_enable=NO alloy_user=alloy alloy_group=alloy sysrc alloy_storage_path=/opt/alloy alloy_listen_address=127.0.0.1:9200
Полная интеграция зависит от адресов приёмников и способа аутентификации организации. В конфигурации использовать password_file с отдельным root:alloy 0640 файлом, проверку CA и уникальную метку host. Для чтения auth/messages предоставить группе alloy доступ 0640 и сохранить те же владельца/группу в newsyslog; членство в широких системных группах не требуется. Проверить alloy validate, доставку контрольных событий, появление CPU/RAM и версий в центральной системе; только затем включить alloy_enable=YES. Этот раздел — параметры подключения, а не готовая конфигурация неизвестного центрального сервера.
14. Обслуживание и диагностика
service exim status service dovecot status exim -bpc exim -bp tail -50 /var/log/exim/mainlog tail -50 /var/log/exim/rejectlog tail -50 /var/log/maillog doveadm user alice@example.org df -hT / /opt openssl x509 -in /usr/local/etc/mail-certs/fullchain.pem -noout -dates -subject
Не публиковать users, приватные DKIM-ключи, TLS-ключ, секреты Alloy и содержимое пользовательских писем. Следить за пространством отдельно на / и /opt. Запланировать продление сертификата до срока истечения; после обновления проверить все его потребители, включая внешний HTTPS frontend. Перед заменой конфигураций сравнивать действующее состояние. Настройка Rspamd с автоматическим обучением при перемещениях INBOX ↔ Junk приведена в разделе 15; этот дополнительный этап выполняется после проверки базовой доставки.
15. Rspamd: оценка почты и автоматическое обучение через IMAP
Этот дополнительный этап выполняется после работоспособной базовой установки Exim и Dovecot 2.4. Пакеты: Rspamd 4.1, Redis, dovecot24-pigeonhole и Python 3.12. Пользователи продолжают храниться в файлах; Redis используется для статистики Rspamd, а не как каталог почтовых ящиков. Данные и очередь обратной связи размещаются на UFS /opt.
Схема рассчитана на общую Bayes-базу всех доменов. Любой пользователь, имеющий доступ к ящику, влияет на её обучение. Настройка autolearn = false отключает обучение по собственным оценкам Rspamd; обучение по явно выполненному пользователем IMAP-действию выполняет отдельная служба.
- Exim очищает внешние X-Spam-заголовки, оценивает только неаутентифицированную SMTP-почту и добавляет локальную оценку.
- Действие Rspamd reject здесь является рекомендацией: по антиспаму нет SMTP-отказов, карантина или удаления. Недоступность Rspamd не блокирует доставку.
- Уже установленный ClamAV продолжает работать по прежней политике: обнаружение вируса отклоняет письмо, отказ сканера даёт временный SMTP-ответ.
- INBOX → Junk создаёт spam-событие; Junk → INBOX — ham-событие. IMAPSieve использует причину COPY как для IMAP MOVE, так и для прямого IMAP COPY между этими папками. APPEND, изменение флагов, Junk → Trash и другие папки не обучают фильтр.
- IMAP-процесс только пишет ограниченное событие на диск. Пароль контроллера и сетевой вызов rspamc находятся в отдельном root-процессе.
15.1. Установить пакеты и подготовить данные
Команды ниже относятся к новой установке выделенных Redis/Rspamd. Если Redis уже обслуживает другие приложения, не заменять его параметры: выделить отдельный экземпляр и согласованно изменить адрес подключения. Выполнять от root в /bin/sh. Проверить доступность /opt и план пакетного менеджера.
mount -p | awk '$2 == "/opt" && $3 == "ufs" { ok=1 } END { exit !ok }'
pkg install -n rspamd redis dovecot24-pigeonhole python312
pkg install -y rspamd redis dovecot24-pigeonhole python312
sysrc redis_enable=NO rspamd_enable=NO rspamd_feedback_enable=NO
service rspamd onestop
service redis onestop
pkg info rspamd redis dovecot24-pigeonhole python312
install -d -o root -g wheel -m 0755 /opt/mail/log
Если новые службы ещё не запущены, onestop может сообщить об отсутствии процесса; проверить sockstat. Следующее перемещение делается ровно один раз: целевые каталоги должны отсутствовать. Пакетные данные сохраняются на новом разделе, а штатные пути становятся ссылками. На уже настроенной системе этот блок не повторять.
test -d /var/db/rspamd && test ! -L /var/db/rspamd || exit 1 test -d /var/log/rspamd && test ! -L /var/log/rspamd || exit 1 test ! -e /opt/mail/rspamd && test ! -e /opt/mail/log/rspamd || exit 1 mv /var/db/rspamd /opt/mail/rspamd mv /var/log/rspamd /opt/mail/log/rspamd ln -s /opt/mail/rspamd /var/db/rspamd ln -s /opt/mail/log/rspamd /var/log/rspamd chown -h root:wheel /var/db/rspamd /var/log/rspamd chown rspamd:rspamd /opt/mail/rspamd /opt/mail/log/rspamd chmod 0750 /opt/mail/rspamd /opt/mail/log/rspamd install -d -o rspamd -g rspamd -m 0700 /opt/mail/rspamd/tmp install -d -o redis -g redis -m 0700 /opt/mail/redis install -d -o redis -g redis -m 0750 /opt/mail/log/redis
В /etc/rc.conf.d/redis добавить required_dirs="/opt/mail/redis /opt/mail/log/redis", в /etc/rc.conf.d/rspamd — required_dirs="/opt/mail/rspamd /opt/mail/log/rspamd". Не стирать другие действующие параметры этих файлов.
15.2. Настроить Redis и Rspamd
Сохранить следующий одноразовый скрипт как /root/setup-rspamd.py, root:wheel 0600. Он предназначен для новой установки, отказывается заменять существующие файлы секретов и не выводит их содержимое. Redis и контроллер слушают только loopback. Локальным процессам также нужен пароль контроллера: secure_ip очищен. Вызов rspamadm для хеша использует сгенерированный пароль как кратковременный аргумент процесса; не запускать этот шаг в среде с недоверенными локальными shell-пользователями. Пароль не записывается в историю команд.
#!/usr/local/bin/python3.12
from pathlib import Path
import grp, os, pwd, secrets, subprocess
assert os.geteuid() == 0
os.umask(0o077)
conf = Path('/usr/local/etc/rspamd')
local = conf / 'local.d'
local.mkdir(exist_ok=True)
def write(path, value, group='wheel', mode=0o640):
path = Path(path)
path.write_text(value, encoding='utf-8')
os.chown(path, 0, grp.getgrnam(group).gr_gid)
os.chmod(path, mode)
redis_secret = conf / 'redis.password'
controller_secret = conf / 'controller.password'
assert not redis_secret.exists() and not controller_secret.exists()
redis_password = secrets.token_urlsafe(36)
controller_password = secrets.token_urlsafe(36)
write(redis_secret, redis_password + '\n', mode=0o600)
write(controller_secret, controller_password + '\n', mode=0o600)
hashed = subprocess.check_output(
['/usr/local/bin/rspamadm', 'pw', '-q', '-p', controller_password], text=True
).strip()
assert hashed.startswith('$'), 'Unexpected password hash format'
redis_conf = Path('/usr/local/etc/redis.conf')
with redis_conf.open('a') as output:
output.write('\n# Dedicated Redis for the mail Rspamd service.\n'
'bind 127.0.0.1\nprotected-mode yes\n'
'dir /opt/mail/redis\n'
'logfile /opt/mail/log/redis/redis.log\nloglevel notice\n'
'appendonly yes\nappendfsync everysec\n'
'maxmemory 256mb\nmaxmemory-policy noeviction\n'
f'requirepass {redis_password}\n')
os.chown(redis_conf, 0, grp.getgrnam('redis').gr_gid)
os.chmod(redis_conf, 0o640)
write(local / 'redis.conf',
f'servers = "127.0.0.1:6379";\npassword = "{redis_password}";\n', group='rspamd')
write(local / 'worker-normal.inc', 'bind_socket = "127.0.0.1:11333";\n')
write(local / 'worker-controller.inc',
'bind_socket = "127.0.0.1:11334";\nsecure_ip = [];\n'
f'password = "{hashed}";\nenable_password = "{hashed}";\n', group='rspamd')
write(local / 'worker-proxy.inc', 'count = 0;\n')
write(local / 'options.inc',
'check_all_filters = true;\ntempdir = "/opt/mail/rspamd/tmp";\n')
write(local / 'logging.inc', 'level = "info";\n')
write(local / 'classifier-bayes.conf',
'autolearn = false;\nper_user = false;\nmin_learns = 200;\n')
for module in ('greylist', 'ratelimit', 'neural', 'force_actions', 'fuzzy_check'):
write(local / (module + '.conf'), 'enabled = false;\n')
write(local / 'phishing.conf',
'phishtank_enabled = false;\nopenphish_enabled = true;\n'
'openphish_premium = false;\n'
'openphish_map = "https://raw.githubusercontent.com/openphish/public_feed/refs/heads/main/feed.txt";\n',
mode=0o644)
print('Configuration written; secrets are stored in root-only files.')
chmod 0600 /root/setup-rspamd.py python3.12 /root/setup-rspamd.py rspamadm configtest sysrc redis_enable=YES rspamd_enable=YES service redis start service rspamd start rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat sockstat -46l | egrep '6379|11333|11334' rm /root/setup-rspamd.py
Проверить отсутствие Redis/Rspamd на внешних адресах. В local.d/options.inc не задан чужой DNS-сервер: используется системный резолвер; для DNSBL нужен разрешённый их политикой резолвер. Пароли не печатать через cat и не включать дамп полного конфигурационного файла в общие логи. При ошибке не подключать Exim, пока configtest, запуск и авторизованный stat не проходят. Значение min_learns = 200 означает, что для работы Bayes-оценок требуются примеры обоих классов: по 200 spam и ham; остальные проверки могут работать до накопления этих примеров.
15.2.1. OpenPhish вместо устаревшего PhishTank
Бесплатный OpenPhish Community Feed подключён через штатный модуль phishing. API-ключ не требуется. Rspamd загружает и автоматически обновляет список по HTTPS, затем проверяет ссылки из писем по локальной копии. Источник phishtank.rspamd.com отключён: эта DNS-зона больше не содержит данных, что отмечено в журнале изменений Rspamd.
Скрипт новой установки выше уже записывает эти параметры. На существующей установке создать или дополнить /usr/local/etc/rspamd/local.d/phishing.conf, сохранив остальные действующие настройки:
phishtank_enabled = false; openphish_enabled = true; openphish_premium = false; openphish_map = "https://raw.githubusercontent.com/openphish/public_feed/refs/heads/main/feed.txt";
Проверить конфигурацию и применить её без перезапуска Exim и Dovecot:
chown root:wheel /usr/local/etc/rspamd/local.d/phishing.conf chmod 0644 /usr/local/etc/rspamd/local.d/phishing.conf rspamadm configtest # Продолжить только после syntax OK: service rspamd reload rspamadm configdump phishing service rspamd status
В итоговой конфигурации должны быть phishtank_enabled = false, openphish_enabled = true и openphish_premium = false. Успешную загрузку списка подтверждает запись parsed N elements from openphish feed в журнале Rspamd, где N > 0.
Точное совпадение ссылки с базой даёт символ PHISHED_OPENPHISH; на проверенной установке его вес — 7 баллов. Порог маркировки — 6 баллов, при этом итоговая оценка учитывает все сработавшие правила. Режим Exim из раздела 15.3 сохраняется: по антиспаму письмо принимается и помечается, а при включённом разделе 15.7 доставляется в Junk («Спам»).
Проверено 29 октября 2026 года: Rspamd загрузил и разобрал 300 ссылок. Письмо со ссылкой из базы получило PHISHED_OPENPHISH, 7 баллов и действие add header; отрицательный контроль этого символа не получил. Тесты выполнены через локальный /checkv2 без SMTP/IMAP-доставки и без обучения: счётчик learned остался 3 → 3. Число 300 относится к этой проверке; состав базы меняется. Конфигурации Exim и доставки Dovecot, пороги и настройка Bayes не изменялись.
Чтобы отключить только OpenPhish, установить openphish_enabled = false;, выполнить rspamadm configtest и после успешной проверки — service rspamd reload. PhishTank оставить выключенным.
Источник настройки: Rspamd: модуль phishing и OpenPhish.
15.3. Подключить Exim без антиспам-отказов
В главной части /usr/local/etc/exim/configure, до begin acl, добавить одну строку:
spamd_address = 127.0.0.1 11333 variant=rspamd tmo=20s
В acl_check_data после всех существующих проверок отправителя и после ClamAV, если он установлен, но перед последним accept, добавить следующий блок. Не заменять существующий malware-блок и не добавлять deny на основе $spam_action.
# Rspamd: annotate unauthenticated incoming mail; keep antivirus policy.
warn
remove_header = X-Spam-Score : X-Spam-Flag : X-Spam-Status : X-Spam-Report : X-Rspamd-Action
warn
!authenticated = *
spam = nobody:true/defer_ok
add_header = X-Spam-Score: $spam_score
add_header = X-Rspamd-Action: $spam_action
add_header = X-Spam-Flag: ${if inlist{$spam_action}{add header:rewrite subject:reject:quarantine:discard}{YES}{NO}}
exim -bV service exim reload exim -bP spamd_address
Проверка true возвращает результат оценки независимо от порога, а defer_ok позволяет продолжить SMTP при недоступном Rspamd. Очистка заголовков применяется ко всем SMTP-сообщениям, оценка — только к неаутентифицированным. Отправка через AUTH 465/587 не сканируется Rspamd и не может оставить собственную поддельную метку. Защита ClamAV действует независимо. Имена X-Spam-заголовков должны совпадать с необязательным правилом доставки ниже.
15.4. Создать защищённую очередь обратной связи
install -d -o root -g vmail -m 0750 /opt/mail/rspamd-feedback install -d -o vmail -g vmail -m 0700 /opt/mail/rspamd-feedback/pending install -d -o root -g wheel -m 0700 /opt/mail/rspamd-feedback/processed install -d -o root -g wheel -m 0755 /usr/local/libexec/rspamd-feedback install -d -o root -g wheel -m 0755 /usr/local/etc/dovecot/rspamd
Ниже приведены три обязательных файла: сборщик, административная библиотека/CLI и фоновый обработчик. Worker импортирует административный модуль, тот импортирует сборщик: пропускать feedback-admin.py нельзя, даже если ручное обучение не используется. Все три файла принадлежат root:wheel, режим 0755, каталог не доступен на запись vmail. Сборщик принимает только фиксированные переходы INBOX/Junk, сообщение не более 50 MiB и ограниченную локальную очередь; к Rspamd он не обращается.
/usr/local/libexec/rspamd-feedback/feedback.py
#!/usr/local/bin/python3.12
"""IMAPSieve collector: bounded local spool only; never contacts Rspamd.
Usage: feedback.py [--queue DIR] spam|ham USER SOURCE DESTINATION
The queue must already exist and be owned by the IMAP mail UID, mode 0700.
Programs/configuration must be root-owned and not writable by that UID.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import secrets
import shutil
import stat
import sys
import time
from contextlib import contextmanager
from datetime import datetime, timezone
MAX_MESSAGE = 50 * 1024 * 1024
DEFAULT_QUEUE = "/opt/mail/rspamd-feedback/pending"
MAX_PENDING_BYTES = 512 * 1024 * 1024
MAX_PENDING_EVENTS = 1024
MIN_FREE_BYTES = 1024 * 1024 * 1024
class FeedbackError(Exception):
pass
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def fsync_directory(path):
if os.name == "posix":
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def validate_event(label, user, source, destination):
expected = {"spam": ("INBOX", "Junk"), "ham": ("Junk", "INBOX")}
if label not in expected or (source, destination) != expected[label]:
raise FeedbackError("event is outside the configured INBOX/Junk transitions")
if not user or len(user) > 320 or any(ord(c) < 32 or ord(c) == 127 for c in user):
raise FeedbackError("invalid IMAP user identity")
@contextmanager
def collection_lock(queue):
"""At most two seconds waiting; IMAP must not wait indefinitely on a lock."""
fd = os.open(queue / ".collector.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
try:
if os.name == "posix":
import fcntl
until = time.monotonic() + 2
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= until:
raise FeedbackError("queue lock is busy")
time.sleep(0.02)
yield
finally:
os.close(fd)
def pending_usage(queue, max_events=MAX_PENDING_EVENTS):
count = total = 0
with os.scandir(queue) as entries:
for entry in entries:
if entry.name == ".collector.lock":
continue
count += 1
if count >= max_events:
raise FeedbackError("pending event limit reached")
if not entry.is_dir(follow_symlinks=False):
raise FeedbackError("unexpected item in pending queue")
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
directory = os.open(entry.path, flags) if os.name == "posix" else None
except FileNotFoundError:
continue
try:
for name in ("message.eml", "metadata.json"):
try:
info = (os.stat(name, dir_fd=directory, follow_symlinks=False)
if directory is not None else (Path(entry.path) / name).lstat())
except FileNotFoundError:
continue
if not stat.S_ISREG(info.st_mode):
raise FeedbackError("unexpected file type in pending event")
total += info.st_size
finally:
if directory is not None:
os.close(directory)
return total
def collect(stream, queue, label, user, source, destination, *, max_bytes=MAX_MESSAGE,
max_pending_bytes=MAX_PENDING_BYTES, max_events=MAX_PENDING_EVENTS,
min_free_bytes=MIN_FREE_BYTES):
validate_event(label, user, source, destination)
queue = Path(queue)
info = queue.lstat()
if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise FeedbackError("queue must be a real directory")
if os.name == "posix" and (info.st_uid != os.geteuid() or info.st_mode & 0o077):
raise FeedbackError("queue must belong to this UID and have mode 0700")
with collection_lock(queue):
current_bytes = pending_usage(queue, max_events)
# Reserve metadata and preserve 1 GiB even for a maximum-sized message.
if current_bytes + 16384 >= max_pending_bytes:
raise FeedbackError("pending byte limit reached")
if shutil.disk_usage(queue).free < min_free_bytes + max_bytes + 16384:
raise FeedbackError("insufficient free disk space")
return _write_event(stream, queue, label, user, source, destination,
max_bytes, min(max_bytes, max_pending_bytes - current_bytes - 16384))
def _write_event(stream, queue, label, user, source, destination, max_bytes, available_bytes):
event_id = secrets.token_hex(16)
temporary = queue / (".tmp-" + event_id)
final = queue / event_id
temporary.mkdir(mode=0o700)
total = 0
digest = hashlib.sha256()
try:
fd = os.open(temporary / "message.eml", os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
with os.fdopen(fd, "wb") as output:
while True:
block = stream.read(min(65536, max_bytes + 1 - total))
if not block:
break
total += len(block)
if total > max_bytes:
raise FeedbackError("message exceeds the 50 MiB collection limit")
if total > available_bytes:
raise FeedbackError("pending byte limit reached")
digest.update(block)
output.write(block)
if total == 0:
raise FeedbackError("empty message")
output.flush()
os.fsync(output.fileno())
metadata = {
"schema": 1, "id": event_id, "created_utc": utc_now(),
"created_ns": time.time_ns(),
"label": label, "user": user, "source": source,
"destination": destination, "bytes": total,
"sha256": digest.hexdigest(),
}
fd = os.open(temporary / "metadata.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as output:
json.dump(metadata, output, ensure_ascii=True, sort_keys=True)
output.write("\n")
output.flush()
os.fsync(output.fileno())
fsync_directory(temporary)
os.rename(temporary, final)
fsync_directory(queue)
return metadata
except Exception:
# Remove only our two known partial files, never recurse into a directory.
for name in ("message.eml", "metadata.json"):
try:
(temporary / name).unlink()
except FileNotFoundError:
pass
try:
temporary.rmdir()
except FileNotFoundError:
pass
raise
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--queue", default=DEFAULT_QUEUE)
parser.add_argument("label", choices=("spam", "ham"))
parser.add_argument("user")
parser.add_argument("source")
parser.add_argument("destination")
args = parser.parse_args(argv)
os.umask(0o077)
try:
collect(sys.stdin.buffer, args.queue, args.label, args.user, args.source, args.destination)
return 0
except (FeedbackError, OSError) as exc:
# No message data, addresses, paths supplied by users, or tracebacks in logs.
detail = str(exc) if isinstance(exc, FeedbackError) else "local IO error errno=" + str(exc.errno)
message = "rspamd-feedback: collection failed: " + detail + "; no training attempted"
try:
import syslog
syslog.openlog("rspamd-feedback", syslog.LOG_PID, syslog.LOG_MAIL)
syslog.syslog(syslog.LOG_ERR, message)
except ImportError:
pass
print(message, file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
/usr/local/libexec/rspamd-feedback/feedback-admin.py
Это общая проверка событий, защищённые снимки, журнал результатов и диагностические команды. Ручная learn-команда сохранена для исключительных случаев; обычные IMAP-события обрабатывает worker из следующего файла.
#!/usr/local/bin/python3.12
"""Root-only review and explicit training of IMAPSieve feedback.
No command trains automatically. list/show are local reads. learn ID spam|ham
is the only command that invokes rspamc. reject ID archives without training.
Queue root and processed must be root-owned, non-writable by mail users;
pending is separately owned by vmail, mode 0700. Archived originals remain in
processed/ID/source; only root-owned snapshots are used for training or review.
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
from email import policy
from email.parser import BytesHeaderParser
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
spec = importlib.util.spec_from_file_location("feedback_collector", Path(__file__).with_name("feedback.py"))
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
MAX_MESSAGE = collector.MAX_MESSAGE
FeedbackError = collector.FeedbackError
ID_RE = re.compile(r"[0-9a-f]{32}\Z")
DEFAULT_ROOT = "/opt/mail/rspamd-feedback"
DEFAULT_PASSWORD_FILE = "/usr/local/etc/rspamd/controller.password"
HEADERS = ("From", "To", "Date", "Subject", "Message-ID", "X-Spam-Status", "X-Spam-Score", "X-Rspamd-Action")
def emit(value):
print(json.dumps(value, ensure_ascii=True, sort_keys=True))
def require_root():
if not hasattr(os, "geteuid") or os.geteuid() != 0:
raise FeedbackError("this administrative command requires root")
def check_id(event_id):
if not ID_RE.fullmatch(event_id):
raise FeedbackError("ID must be exactly 32 lowercase hexadecimal characters")
return event_id
class Directory:
"""Nofollow directory descriptor on FreeBSD; portable fallback for local tests."""
def __init__(self, path):
self.path = Path(path)
info = self.path.lstat()
if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise FeedbackError("expected a real directory")
self.fd = None
if os.name == "posix":
self.fd = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
def __enter__(self):
return self
def __exit__(self, *args):
if self.fd is not None:
os.close(self.fd)
def read(self, name, limit):
if "/" in name or "\\" in name or name in (".", ".."):
raise FeedbackError("invalid internal filename")
if self.fd is not None:
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=self.fd)
else:
path = self.path / name
if path.is_symlink():
raise FeedbackError("symlink is not an event file")
fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0))
with os.fdopen(fd, "rb") as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > limit:
raise FeedbackError("event file is not a bounded, single-link regular file")
result = source.read(limit + 1)
if len(result) > limit:
raise FeedbackError("event file exceeded the size limit")
return result
def read_event(directory, event_id):
check_id(event_id)
# On POSIX open the event relative to the already opened parent descriptor.
# The parent path is deployment-owned, and a symlink event is rejected.
with Directory(Path(directory) / event_id) as entry:
metadata = json.loads(entry.read("metadata.json", 16384))
message = entry.read("message.eml", MAX_MESSAGE)
if not isinstance(metadata, dict) or not isinstance(metadata.get("user"), str):
raise FeedbackError("invalid event metadata")
collector.validate_event(metadata.get("label"), metadata.get("user", ""),
metadata.get("source"), metadata.get("destination"))
if metadata.get("id") != event_id or metadata.get("schema") != 1:
raise FeedbackError("invalid event metadata")
if metadata.get("bytes") != len(message) or metadata.get("sha256") != hashlib.sha256(message).hexdigest():
raise FeedbackError("event content does not match its metadata")
return metadata, message
def write_private(path, value, *, json_value=False):
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
fd = os.open(path, flags, 0o600)
with os.fdopen(fd, "wb") as output:
if json_value:
value = (json.dumps(value, ensure_ascii=True, sort_keys=True) + "\n").encode()
output.write(value)
output.flush()
os.fsync(output.fileno())
def update_receipt(directory, receipt):
temporary = directory / ".admin-new.json"
write_private(temporary, receipt, json_value=True)
os.replace(temporary, directory / "admin.json")
collector.fsync_directory(directory)
def check_root_directory(path):
with Directory(path):
info = Path(path).stat()
if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o022):
raise FeedbackError("administrative directories must be root-owned and not group/other writable")
@contextmanager
def admin_lock(root):
check_root_directory(root)
processed = root / "processed"
if not processed.exists():
processed.mkdir(mode=0o700)
check_root_directory(processed)
fd = os.open(root / ".admin.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
try:
if os.name == "posix":
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def receipt_for(directory):
with Directory(directory) as entry:
return json.loads(entry.read("admin.json", 16384))
def find_prior(root, sha256):
processed = root / "processed"
if not processed.exists():
return None
for entry in processed.iterdir():
if not ID_RE.fullmatch(entry.name):
continue
try:
receipt = receipt_for(entry)
except (OSError, ValueError, FeedbackError):
# A damaged journal must never silently allow repeated training.
raise FeedbackError("processed journal is incomplete; inspect it before more training")
if receipt.get("sha256") == sha256 and receipt.get("status") in ("preparing", "learning", "learned", "learn_uncertain"):
return receipt
return None
def prepare_archive(root, event_id, metadata, message, status, label=None, reason=None):
if shutil.disk_usage(root).free < collector.MIN_FREE_BYTES + len(message) + 32768:
raise FeedbackError("insufficient free disk space to preserve the reviewed snapshot")
archive = root / "processed" / event_id
archive.mkdir(mode=0o700)
receipt = {"schema": 1, "id": event_id, "sha256": metadata["sha256"],
"reviewed_utc": collector.utc_now(), "status": "preparing",
"selected_class": label, "reason": reason}
write_private(archive / "admin.json", receipt, json_value=True)
write_private(archive / "message.eml", message)
write_private(archive / "metadata.json", json.dumps(metadata, ensure_ascii=True).encode())
# Keep the complete original event. Never traverse or execute files in source/.
os.rename(root / "pending" / event_id, archive / "source")
receipt["status"] = status
update_receipt(archive, receipt)
collector.fsync_directory(root / "pending")
collector.fsync_directory(root / "processed")
return archive, receipt
def run_learning(message, label, *, executable, controller, password_file=None, timeout=30):
executable = Path(executable)
if not executable.is_absolute():
raise FeedbackError("rspamc executable must be an absolute path")
command = [str(executable), "-h", controller]
if password_file:
secret = Path(password_file)
info = secret.lstat()
if not secret.is_absolute() or not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise FeedbackError("password file must be an absolute regular file")
if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o077):
raise FeedbackError("password file must be root-owned, mode 0600 or stricter")
# rspamc reads the file itself; the secret never appears in argv or logs.
command.extend(["-P", str(secret)])
command.append("learn_" + label)
# No -u/-d: one shared Bayes classifier. No shell and no automatic retry.
return subprocess.run(command, input=message, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=timeout, check=False).returncode
def learn_event(root, event_id, label, *, executable, controller, password_file=None, timeout=30):
check_id(event_id)
if label not in ("spam", "ham"):
raise FeedbackError("invalid training class")
with admin_lock(root):
metadata, message = read_event(root / "pending", event_id)
prior = find_prior(root, metadata["sha256"])
if prior:
raise FeedbackError("this content has a prior learned or uncertain event; inspect it and reject the duplicate")
archive, receipt = prepare_archive(root, event_id, metadata, message, "learning", label)
try:
rc = run_learning(message, label, executable=executable, controller=controller,
password_file=password_file, timeout=timeout)
except subprocess.TimeoutExpired:
receipt["status"] = "learn_uncertain"
receipt["error"] = "client timeout; server may have learned; do not automatically retry"
except (OSError, FeedbackError):
receipt["status"] = "learn_failed"
receipt["error"] = "client could not start or local configuration was invalid"
else:
receipt["returncode"] = rc
receipt["status"] = "learned" if rc == 0 else "learn_uncertain"
if rc:
receipt["error"] = "client returned failure; inspect server before any retry"
receipt["finished_utc"] = collector.utc_now()
update_receipt(archive, receipt)
return receipt
def reject_event(root, event_id, reason="review rejected"):
check_id(event_id)
if len(reason) > 500:
raise FeedbackError("reason is too long")
with admin_lock(root):
metadata, message = read_event(root / "pending", event_id)
_, receipt = prepare_archive(root, event_id, metadata, message, "rejected", reason=reason)
return receipt
def list_events(root, state, limit):
count = 0
for name in (("pending", "processed") if state == "all" else (state,)):
directory = root / name
if not directory.exists():
continue
with Directory(directory):
for entry in directory.iterdir():
if not ID_RE.fullmatch(entry.name):
continue
if count >= limit:
emit({"notice": "limit reached", "limit": limit})
return
try:
with Directory(entry) as item:
metadata = json.loads(item.read("metadata.json", 16384))
row = {key: metadata.get(key) for key in ("id", "created_utc", "label", "user", "source", "destination", "bytes")}
row["state"] = name
if name == "processed":
row["review"] = receipt_for(entry)
emit(row)
except (OSError, ValueError, FeedbackError):
emit({"id": entry.name, "state": name, "error": "invalid event; manual inspection required"})
count += 1
def show_event(root, event_id, state, path_only):
metadata, message = read_event(root / state, check_id(event_id))
if path_only:
emit({"id": event_id, "message_path": str(root / state / event_id / "message.eml")})
return
headers = BytesHeaderParser(policy=policy.default).parsebytes(message[:min(len(message), 262144)])
emit({"metadata": metadata, "headers": {name: [str(v)[:2000] for v in headers.get_all(name, [])[:10]] for name in HEADERS}})
if state == "processed":
emit({"review": receipt_for(root / state / event_id)})
def warn_processed_storage(root):
processed = root / "processed"
if not processed.exists():
return
total = count = 0
for event in processed.iterdir():
if not ID_RE.fullmatch(event.name) or event.is_symlink() or not event.is_dir():
continue
count += 1
for prefix in (event, event / "source"):
if prefix.is_symlink():
continue
for name in ("message.eml", "metadata.json", "admin.json"):
try:
info = (prefix / name).lstat()
if stat.S_ISREG(info.st_mode):
total += info.st_size
except FileNotFoundError:
pass
if total >= collector.MAX_PENDING_BYTES or count >= collector.MAX_PENDING_EVENTS:
print("rspamd-feedback-admin: WARNING processed archive reached 512 MiB or 1024 events; review retention manually; nothing deleted", file=sys.stderr)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--queue-root", default=DEFAULT_ROOT)
sub = parser.add_subparsers(dest="command", required=True)
listing = sub.add_parser("list", help="JSON lines, metadata only")
listing.add_argument("--state", choices=("pending", "processed", "all"), default="pending")
listing.add_argument("--limit", type=int, default=100)
showing = sub.add_parser("show", help="selected headers only; never dumps the body")
showing.add_argument("id")
showing.add_argument("--state", choices=("pending", "processed"), default="pending")
showing.add_argument("--path", action="store_true")
learning = sub.add_parser("learn", help="explicitly train one reviewed event, then archive it")
learning.add_argument("id")
learning.add_argument("label", choices=("spam", "ham"))
learning.add_argument("--rspamc-bin", default="/usr/local/bin/rspamc")
learning.add_argument("--controller", default="127.0.0.1:11334")
learning.add_argument("--password-file", default=DEFAULT_PASSWORD_FILE,
help="root-only plaintext secret file; use --no-password only for an intentionally trusted local controller")
learning.add_argument("--no-password", action="store_true")
learning.add_argument("--timeout", type=float, default=30)
rejecting = sub.add_parser("reject", help="archive one event without training or deleting it")
rejecting.add_argument("id")
rejecting.add_argument("--reason", default="review rejected")
args = parser.parse_args(argv)
os.umask(0o077)
try:
require_root()
root = Path(args.queue_root)
if not root.is_absolute():
raise FeedbackError("queue root must be absolute")
check_root_directory(root)
warn_processed_storage(root)
if args.command == "list":
if not 1 <= args.limit <= 10000:
raise FeedbackError("limit must be between 1 and 10000")
list_events(root, args.state, args.limit)
elif args.command == "show":
show_event(root, args.id, args.state, args.path)
elif args.command == "reject":
emit(reject_event(root, args.id, args.reason))
else:
if not 0 < args.timeout <= 300:
raise FeedbackError("timeout must be between 0 and 300 seconds")
result = learn_event(root, args.id, args.label, executable=args.rspamc_bin,
controller=args.controller, password_file=None if args.no_password else args.password_file,
timeout=args.timeout)
emit(result)
return 0 if result["status"] == "learned" else 2
return 0
except (FeedbackError, OSError, ValueError, TypeError):
print("rspamd-feedback-admin: operation refused or failed; inspect configuration/event locally", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
/usr/local/libexec/rspamd-feedback/feedback-worker.py
Служба обрабатывает события по точному времени, сохраняет намерение до запроса обучения и исключает повтор одного и того же класса для того же содержимого. Обратный переход передаёт противоположный класс. Недоступный до запроса контроллер оставляет событие pending. Неопределённый результат уже отправленного запроса автоматически не повторяется.
#!/usr/local/bin/python3.12
"""Root-only background learning from the bounded IMAPSieve feedback queue.
The collector remains a local spool writer. This process owns controller
credentials, learns only validated INBOX/Junk events, preserves every original,
and never retries a training operation with an uncertain result. Run foreground
under FreeBSD daemon(8), or use --once for one bounded batch.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import importlib.util
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import threading
import time
_spec = importlib.util.spec_from_file_location("feedback_worker_admin", Path(__file__).with_name("feedback-admin.py"))
admin = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(admin)
FeedbackError = admin.FeedbackError
SHA_RE = re.compile(r"[0-9a-f]{64}\Z")
MAX_JOURNAL_EVENTS = 20000
UNCERTAIN = {"preparing", "learning", "learn_uncertain", "quarantined_uncertain", "quarantined_ordering"}
def log_event(kind, **fields):
# Callers pass only fixed diagnostics, numeric counts, event IDs and classes.
print(json.dumps({"event": kind, **fields}, ensure_ascii=True, sort_keys=True), flush=True)
def _timestamp_ns(value):
if not isinstance(value, str):
raise ValueError("missing timestamp")
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("timestamp must have timezone")
return int(parsed.timestamp() * 1_000_000_000)
def order_events(root):
"""Read bounded metadata, ordered by collector created_ns, then event ID.
Legacy second-resolution timestamps are permitted only when the same
content has no contradictory labels at an identical timestamp. Such a tie
cannot be resolved from random event IDs and is quarantined without learning.
"""
pending = Path(root) / "pending"
rows = []
count = 0
with admin.Directory(pending):
with os.scandir(pending) as entries:
for entry in entries:
if not admin.ID_RE.fullmatch(entry.name):
continue
count += 1
if count > admin.collector.MAX_PENDING_EVENTS:
raise FeedbackError("pending queue scan bound exceeded")
try:
with admin.Directory(pending / entry.name) as directory:
metadata = json.loads(directory.read("metadata.json", 16384))
if not isinstance(metadata, dict):
raise ValueError("invalid metadata")
metadata = dict(metadata)
if metadata.get("id") != entry.name or metadata.get("schema") != 1:
raise ValueError("invalid event identity")
if not isinstance(metadata.get("sha256"), str) or not SHA_RE.fullmatch(metadata["sha256"]):
raise ValueError("invalid digest")
admin.collector.validate_event(metadata.get("label"), metadata.get("user", ""),
metadata.get("source"), metadata.get("destination"))
timestamp = metadata.get("created_ns")
if isinstance(timestamp, int) and not isinstance(timestamp, bool) and timestamp > 0:
metadata["_order_ns"] = timestamp
elif "created_ns" not in metadata:
metadata["_order_ns"] = _timestamp_ns(metadata.get("created_utc"))
else:
metadata["_order_ns"] = 0
metadata["_ordering_invalid"] = True
rows.append(metadata)
except FileNotFoundError:
# A manual administrator may have archived this candidate.
continue
except (OSError, ValueError, TypeError, FeedbackError):
rows.append({"id": entry.name, "_invalid": True, "_order_ns": 0})
tied = {}
for row in rows:
if not row.get("_invalid"):
tied.setdefault((row["_order_ns"], row["sha256"]), set()).add(row["label"])
for row in rows:
if not row.get("_invalid") and len(tied[(row["_order_ns"], row["sha256"])]) > 1:
row["_ordering_invalid"] = True
return sorted(rows, key=lambda row: (row["_order_ns"], row["id"]))
def _journal(root):
"""Read root-owned receipts; successful classes use durable worker sequence."""
processed = Path(root) / "processed"
latest, blocked = {}, set()
max_sequence = used_bytes = count = 0
if not processed.exists():
return latest, blocked, max_sequence, used_bytes, count
admin.check_root_directory(processed)
with os.scandir(processed) as entries:
for entry in entries:
if not admin.ID_RE.fullmatch(entry.name):
continue
count += 1
if count > MAX_JOURNAL_EVENTS:
raise FeedbackError("processed journal scan bound exceeded")
directory = processed / entry.name
receipt = admin.receipt_for(directory)
if not isinstance(receipt, dict) or receipt.get("id") != entry.name:
raise FeedbackError("invalid processed receipt")
digest = receipt.get("sha256")
if not isinstance(digest, str) or not SHA_RE.fullmatch(digest):
raise FeedbackError("invalid processed digest")
sequence = receipt.get("worker_sequence", 0)
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
raise FeedbackError("invalid processed sequence")
max_sequence = max(max_sequence, sequence)
status = receipt.get("status")
if status in UNCERTAIN:
blocked.add(digest)
if status == "learned":
label = receipt.get("selected_class")
if label not in ("spam", "ham"):
raise FeedbackError("invalid learned class")
# Existing manual receipts predate automatic processing; the old
# admin refuses further training of a SHA once it has a receipt.
fallback = _timestamp_ns(receipt.get("finished_utc", receipt.get("reviewed_utc")))
position = (bool(sequence), sequence if sequence else fallback, entry.name)
if digest not in latest or position > latest[digest][0]:
latest[digest] = (position, label)
# Count snapshots and preserved originals, never recursively walk
# arbitrary files in the mail-user-owned source/ directory.
for part in (directory, directory / "source"):
try:
with admin.Directory(part) as safe:
for name in ("message.eml", "metadata.json", "admin.json"):
try:
if safe.fd is not None:
info = os.stat(name, dir_fd=safe.fd, follow_symlinks=False)
else:
info = (part / name).lstat()
if stat.S_ISREG(info.st_mode):
used_bytes += info.st_size
except FileNotFoundError:
continue
except FileNotFoundError:
continue
return latest, blocked, max_sequence, used_bytes, count
def _check_trusted_parent_chain(path):
if os.name != "posix":
return
for parent in Path(path).parents:
link_info = parent.lstat()
info = parent.stat()
if (link_info.st_uid != 0 or not stat.S_ISDIR(info.st_mode)
or info.st_uid != 0 or info.st_mode & 0o022):
raise FeedbackError("executable parent directories must be controlled by root")
def resolve_executable(executable):
"""Allow FreeBSD package symlinks, but pin both operations to their target."""
source = Path(executable)
if not source.is_absolute():
raise FeedbackError("rspamc executable must be absolute")
source_info = source.lstat()
if os.name == "posix" and source_info.st_uid != 0:
raise FeedbackError("rspamc entry must be owned by root")
_check_trusted_parent_chain(source)
target = source.resolve(strict=True)
info = target.lstat()
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise FeedbackError("rspamc target must be a regular file")
if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o022):
raise FeedbackError("rspamc target must be root-owned and not writable by mail users")
_check_trusted_parent_chain(target)
return str(target)
def controller_ready(executable, controller, password_file, timeout):
"""Authenticated read-only controller probe; no candidate is archived yet."""
command = [resolve_executable(executable), "-h", controller]
if password_file:
secret = Path(password_file)
info = secret.lstat()
if not secret.is_absolute() or not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise FeedbackError("unsafe controller password file")
if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o077):
raise FeedbackError("controller password file must be root-owned mode 0600 or stricter")
command.extend(["-P", str(secret)])
command.append("stat")
try:
return subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=False, timeout=min(timeout, 5)).returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
def process_pending(root, *, executable="/usr/local/bin/rspamc", controller="127.0.0.1:11334",
password_file=admin.DEFAULT_PASSWORD_FILE, timeout=30, batch_size=20,
preflight=None, learner=None, stop_event=None):
"""Process at most batch_size candidates. Inject probes/learner for unit tests.
One admin lock serializes manual and background operations. A crash after a
durable learning intent leaves status=learning, which blocks this SHA on the
next pass; subsequent candidates are preserved as quarantined_uncertain.
"""
root = Path(root)
preflight = controller_ready if preflight is None else preflight
if learner is None:
# FreeBSD pkg installs rspamc as a root-owned versioned symlink. Resolve
# it once so preflight and training invoke the same trusted executable.
executable = resolve_executable(executable)
learner = admin.run_learning
summary = {"learned": 0, "duplicates": 0, "quarantined": 0, "invalid": 0,
"pending": 0, "controller_unavailable": False, "storage_blocked": False,
"archive_warning": False}
with admin.admin_lock(root):
events = order_events(root)
summary["pending"] = len(events)
if not events:
return summary
latest, blocked, sequence, used_bytes, archive_count = _journal(root)
summary["archive_warning"] = archive_count >= admin.collector.MAX_PENDING_EVENTS
if used_bytes >= admin.collector.MAX_PENDING_BYTES:
summary["storage_blocked"] = True
return summary
if not preflight(executable, controller, password_file, timeout):
summary["controller_unavailable"] = True
return summary
handled = 0
for candidate in events:
if handled >= batch_size or (stop_event is not None and stop_event.is_set()):
break
if candidate.get("_invalid"):
summary["invalid"] += 1
continue
event_id = candidate["id"]
if (root / "processed" / event_id).exists():
# Crash before prepare_archive moved pending/ID: its root-owned
# intent is authoritative. Preserve the pending original for
# manual recovery and continue independent content, never retry.
summary["invalid"] += 1
continue
try:
metadata, message = admin.read_event(root / "pending", event_id)
except FileNotFoundError:
continue
except (OSError, ValueError, TypeError, FeedbackError):
summary["invalid"] += 1
continue
# Do not act if the queued ordering/identity changed since enumeration.
if any(metadata.get(key) != candidate.get(key) for key in
("sha256", "label", "created_ns", "created_utc")):
summary["invalid"] += 1
continue
if used_bytes + 2 * len(message) + 32768 > admin.collector.MAX_PENDING_BYTES:
summary["storage_blocked"] = True
break
label, digest = metadata["label"], metadata["sha256"]
if candidate.get("_ordering_invalid"):
status, reason = "quarantined_ordering", "event ordering is ambiguous"
elif digest in blocked:
status, reason = "quarantined_uncertain", "a prior learning operation has an uncertain result"
elif digest in latest and latest[digest][1] == label:
status, reason = "duplicate", "same class as the latest successful learning operation"
else:
status, reason = "learning", "automatic explicit IMAP feedback"
try:
archive, receipt = admin.prepare_archive(root, event_id, metadata, message, status, label, reason)
except (OSError, ValueError, FeedbackError):
# prepare_archive writes a root journal before moving the source;
# stop instead of retrying an incompletely prepared operation.
summary["storage_blocked"] = True
break
sequence += 1
receipt.update({"mode": "automatic_imap_feedback", "worker_sequence": sequence,
"processed_ns": time.time_ns(), "source_created_ns": candidate["_order_ns"]})
if digest in latest:
receipt["previous_successful_class"] = latest[digest][1]
# Durable intent exists before the only call that may change Bayes.
admin.update_receipt(archive, receipt)
if status == "learning":
try:
returncode = learner(message, label, executable=executable, controller=controller,
password_file=password_file, timeout=timeout)
except (subprocess.TimeoutExpired, OSError, FeedbackError):
receipt["status"] = "learn_uncertain"
receipt["error"] = "client failure; server may have learned; no automatic retry"
else:
receipt["returncode"] = returncode
receipt["status"] = "learned" if returncode == 0 else "learn_uncertain"
if returncode != 0:
receipt["error"] = "client returned failure; no automatic retry"
receipt["finished_utc"] = admin.collector.utc_now()
admin.update_receipt(archive, receipt)
if receipt["status"] == "learned":
latest[digest] = ((True, sequence, event_id), label)
summary["learned"] += 1
else:
blocked.add(digest)
summary["quarantined"] += 1
elif status == "duplicate":
summary["duplicates"] += 1
else:
blocked.add(digest)
summary["quarantined"] += 1
handled += 1
used_bytes += 2 * len(message) + 32768
archive_count += 1
summary["pending"] -= 1
summary["archive_warning"] = archive_count >= admin.collector.MAX_PENDING_EVENTS
log_event("feedback_processed", id=event_id, label=label, status=receipt["status"])
if receipt["status"] == "learn_uncertain":
# The controller may have failed after the preflight. Leave all
# other candidates pending until a fresh read-only probe passes.
break
return summary
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--queue-root", default=admin.DEFAULT_ROOT)
parser.add_argument("--rspamc-bin", default="/usr/local/bin/rspamc")
parser.add_argument("--controller", default="127.0.0.1:11334")
parser.add_argument("--password-file", default=admin.DEFAULT_PASSWORD_FILE)
parser.add_argument("--poll", type=float, default=2)
parser.add_argument("--timeout", type=float, default=30)
parser.add_argument("--batch-size", type=int, default=20)
parser.add_argument("--once", action="store_true")
args = parser.parse_args(argv)
os.umask(0o077)
admin.require_root()
root = Path(args.queue_root)
if not root.is_absolute() or not 0.2 <= args.poll <= 60 or not 0 < args.timeout <= 300 or not 1 <= args.batch_size <= 100:
raise FeedbackError("invalid worker options")
admin.check_root_directory(root)
stopping = threading.Event()
for signum in (signal.SIGTERM, signal.SIGINT):
signal.signal(signum, lambda *_: stopping.set())
lockfd = os.open(root / ".worker.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
try:
if os.name == "posix":
import fcntl
fcntl.flock(lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
previous_state = None
while not stopping.is_set():
try:
summary = process_pending(root, executable=args.rspamc_bin, controller=args.controller,
password_file=args.password_file, timeout=args.timeout,
batch_size=args.batch_size, stop_event=stopping)
state = {key: value for key, value in summary.items() if key not in ("learned", "duplicates", "quarantined")}
if state != previous_state or any(summary[key] for key in ("learned", "duplicates", "quarantined")):
log_event("worker_state", **summary)
previous_state = state
if args.once:
return 2 if summary["controller_unavailable"] or summary["storage_blocked"] else 0
except (OSError, ValueError, TypeError, FeedbackError):
if previous_state != "local_error":
log_event("worker_error", reason="local queue, journal or configuration failure; no automatic retry of uncertain training")
previous_state = "local_error"
if args.once:
return 1
stopping.wait(args.poll)
return 0
finally:
os.close(lockfd)
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, TypeError, FeedbackError):
log_event("worker_start_failed", reason="root, configuration or worker lock required")
raise SystemExit(1)
Обёртки запуска и права
Создать /usr/local/libexec/rspamd-feedback/collector:
#!/bin/sh exec /usr/local/bin/python3.12 /usr/local/libexec/rspamd-feedback/feedback.py "$@"
Создать /usr/local/sbin/rspamd-feedback:
#!/bin/sh exec /usr/local/bin/python3.12 /usr/local/libexec/rspamd-feedback/feedback-admin.py "$@"
chown root:wheel /usr/local/libexec/rspamd-feedback/* /usr/local/sbin/rspamd-feedback chmod 0755 /usr/local/libexec/rspamd-feedback/* /usr/local/sbin/rspamd-feedback python3.12 -m py_compile /usr/local/libexec/rspamd-feedback/feedback.py /usr/local/libexec/rspamd-feedback/feedback-admin.py /usr/local/libexec/rspamd-feedback/feedback-worker.py
15.5. Подключить IMAPSieve
Сохранить /usr/local/etc/dovecot/rspamd-feedback.conf как root:wheel 0644. Блок inbox дополняет существующий namespace, не заменяет его.
# Dovecot CE 2.4.5 / Pigeonhole 2.4: local IMAP feedback collection for the background worker.
# Include explicitly from the end of the existing monolithic dovecot.conf.
# Install root:wheel 0644. Precompile the two administrator scripts with sievec.
# No Sieve plugin is enabled for LMTP/LDA and no incoming mail is moved.
protocol imap {
mail_plugins {
imap_sieve = yes
}
}
sieve_plugins {
sieve_imapsieve = yes
sieve_extprograms = yes
}
# External commands are available only to administrator-controlled scripts.
sieve_global_extensions {
vnd.dovecot.pipe = yes
vnd.dovecot.environment = yes
}
sieve_pipe_bin_dir = /usr/local/libexec/rspamd-feedback
sieve_pipe_exec_timeout = 3s
# The existing inbox namespace is private, has no prefix, and uses '/' as its
# separator. Exact names deliberately exclude shared namespaces, subfolders,
# Trash/Sent/Archive, and all movements other than these two directions.
namespace inbox {
mailbox Junk {
auto = subscribe
special_use = \Junk
imapsieve_from INBOX {
sieve_script rspamd-report-spam {
type = before
cause = copy
path = /usr/local/etc/dovecot/rspamd/report-spam.sieve
}
}
}
mailbox INBOX {
imapsieve_from Junk {
sieve_script rspamd-report-ham {
type = before
cause = copy
path = /usr/local/etc/dovecot/rspamd/report-ham.sieve
}
}
}
}
# imapsieve_url is intentionally unset: no user-uploaded IMAPSieve scripts.
# COPY is also the IMAPSieve cause for a client-issued IMAP MOVE command.
# The collector spools reports; the separate root worker trains Bayes automatically.
В конце основного /usr/local/etc/dovecot/dovecot.conf добавить ровно одну строку:
!include /usr/local/etc/dovecot/rspamd-feedback.conf
/usr/local/etc/dovecot/rspamd/report-spam.sieve:
require ["vnd.dovecot.pipe", "copy", "vnd.dovecot.imapsieve",
"vnd.dovecot.environment", "environment", "variables"];
# Recheck the event as well as the Dovecot configuration filters. This excludes
# APPEND/FLAG, shared-folder paths, subfolders and non-INBOX source mailboxes.
if not allof (
environment :is "imap.cause" "COPY",
environment :is :comparator "i;octet" "vnd.dovecot.mailbox-from" "INBOX",
environment :is :comparator "i;octet" "vnd.dovecot.mailbox-to" "Junk"
) {
stop;
}
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
if string :is "${username}" "" {
stop;
}
# Fixed program name; all metadata are separate argv entries, never shell text.
# :copy preserves the message; :try keeps feedback failures non-fatal.
pipe :copy :try "collector" ["spam", "${username}", "INBOX", "Junk"];
/usr/local/etc/dovecot/rspamd/report-ham.sieve:
require ["vnd.dovecot.pipe", "copy", "vnd.dovecot.imapsieve",
"vnd.dovecot.environment", "environment", "variables"];
# Only restoring a message from Junk to INBOX constitutes a ham report.
# Moving to Trash/Sent/Archive, APPEND, FLAG, and shared folders are ignored.
if not allof (
environment :is "imap.cause" "COPY",
environment :is :comparator "i;octet" "vnd.dovecot.mailbox-from" "Junk",
environment :is :comparator "i;octet" "vnd.dovecot.mailbox-to" "INBOX"
) {
stop;
}
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
if string :is "${username}" "" {
stop;
}
# The collector stores a pending report; the background root worker learns it.
pipe :copy :try "collector" ["ham", "${username}", "Junk", "INBOX"];
chown root:wheel /usr/local/etc/dovecot/rspamd-feedback.conf /usr/local/etc/dovecot/rspamd/*.sieve chmod 0644 /usr/local/etc/dovecot/rspamd-feedback.conf /usr/local/etc/dovecot/rspamd/*.sieve doveconf -n sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/report-spam.sieve sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/report-ham.sieve chown root:wheel /usr/local/etc/dovecot/rspamd/*.svbin chmod 0644 /usr/local/etc/dovecot/rspamd/*.svbin service dovecot restart service dovecot status
Рестарт Dovecot кратковременно разрывает существующие IMAP-сеансы; клиенты подключатся заново. Sieve :copy сохраняет письмо, :try не делает ошибку сборщика причиной отказа пользовательского действия. Выполнение внешнего сборщика ограничено 3 секундами, ожидание его блокировки — 2 секундами. LMTP-сортировка не нужна для обучения и на этом шаге ещё не включена.
15.6. Запустить автоматический обработчик
Сохранить /usr/local/etc/rc.d/rspamd_feedback как root:wheel 0555:
#!/bin/sh
# PROVIDE: rspamd_feedback
# REQUIRE: LOGIN redis rspamd
# KEYWORD: shutdown
#
# Install as /usr/local/etc/rc.d/rspamd_feedback, root:wheel 0555.
# Enable with: sysrc rspamd_feedback_enable=YES
# Uses FreeBSD 15.1 daemon(8): rc.subr targets the supervisor PID (-P),
# not the child PID (-p), so stop cannot accidentally restart the worker.
. /etc/rc.subr
name="rspamd_feedback"
desc="Apply user spam/ham feedback to the local Rspamd Bayes classifier"
rcvar="rspamd_feedback_enable"
load_rc_config "${name}"
: ${rspamd_feedback_enable:="NO"}
command="/usr/sbin/daemon"
procname="/usr/sbin/daemon"
pidfile="/var/run/rspamd_feedback.pid"
child_pidfile="/var/run/rspamd_feedback.child.pid"
worker="/usr/local/libexec/rspamd-feedback/feedback-worker.py"
python="/usr/local/bin/python3.12"
logdir="/opt/mail/log/rspamd-feedback"
logfile="${logdir}/worker.log"
required_files="${python} ${worker} /usr/local/libexec/rspamd-feedback/feedback-admin.py /usr/local/etc/rspamd/controller.password"
required_dirs="/opt/mail/rspamd-feedback/pending /opt/mail/rspamd-feedback/processed /opt/mail/rspamd"
# Worker handles transient controller errors in its polling loop. Unexpected
# exits get at most five supervised restarts, spaced 15 seconds apart.
# -H reopens the log on SIGHUP without interrupting an in-flight learn request.
command_args="-c -f -H -R 15 -C 5 -P ${pidfile} -p ${child_pidfile} -t ${name} -o ${logfile} ${python} -u ${worker} --poll 2"
start_precmd="rspamd_feedback_prestart"
extra_commands="reload"
sig_reload="HUP"
rspamd_feedback_prestart()
{
if [ "$(id -u)" -ne 0 ]; then
warn "${name} requires root to read the local controller credential"
return 1
fi
# Refuse to write queue/log data to the root filesystem when /opt failed
# to mount. Directory existence alone does not establish the mount state.
if ! /sbin/mount -p | /usr/bin/awk '$2 == "/opt" { found = 1 } END { exit !found }'; then
warn "${name} requires the mail filesystem mounted at /opt"
return 1
fi
if [ -L "${logdir}" ] || [ -L "${logfile}" ]; then
warn "${name} refuses a symlink at its private log path"
return 1
fi
/usr/bin/install -d -o root -g wheel -m 0700 "${logdir}" || return 1
if [ ! -e "${logfile}" ]; then
/usr/bin/install -o root -g wheel -m 0600 /dev/null "${logfile}" || return 1
fi
/usr/sbin/chown root:wheel "${logfile}" || return 1
/bin/chmod 0600 "${logfile}" || return 1
umask 077
}
run_rc_command "$1"
Сохранить /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf как root:wheel 0644:
# Install /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf root:wheel 0644. # FreeBSD daemon -H receives SIGHUP through its supervisor pidfile and reopens # the file. Keep seven compressed 1 MiB archives; active and rotated logs 0600. /opt/mail/log/rspamd-feedback/worker.log root:wheel 600 7 1024 * JC /var/run/rspamd_feedback.pid 1
chown root:wheel /usr/local/etc/rc.d/rspamd_feedback /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf chmod 0555 /usr/local/etc/rc.d/rspamd_feedback chmod 0644 /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf sh -n /usr/local/etc/rc.d/rspamd_feedback newsyslog -n -f /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat sysrc rspamd_feedback_enable=YES service rspamd_feedback start service rspamd_feedback status tail -50 /opt/mail/log/rspamd-feedback/worker.log
Супервизор daemon имеет отдельные PID-файлы родителя и ребёнка: stop завершает супервизор, не вызывает немедленный перезапуск worker. После неожиданного выхода предусмотрено не более пяти перезапусков с паузой 15 секунд. Проверка монтирования /opt выполняется до открытия журналов.
15.7. Необязательно: автоматическая доставка отмеченного спама в Junk
Этот небольшой отдельный этап помещает новые письма с локальной X-Spam-Flag: YES в Junk. Остальная почта сохраняется во Входящих. Уже лежащие сообщения не перемещаются. Если нужна только обратная связь при ручных перемещениях, этот этап можно пропустить. Автоматическая LMTP-доставка в Junk сама по себе не создаёт IMAP COPY и не обучает фильтр; исправление пользователем Junk → INBOX создаст ham-событие.
Сохранить /usr/local/etc/dovecot/rspamd-spam-delivery.conf как root:wheel 0644:
# Dovecot CE / Pigeonhole 2.4.5: sort marked mail during LMTP delivery only.
# Include explicitly from the existing monolithic dovecot.conf.
# Install this fragment and spam-delivery.sieve root:wheel 0644, and precompile
# the script with sievec -c /usr/local/etc/dovecot/dovecot.conf; make the resulting
# .svbin root:wheel 0644 so the vmail LMTP process can read it.
protocol lmtp {
mail_plugins {
sieve = yes
}
sieve_script rspamd-spam-delivery {
type = before
cause = delivery
path = /usr/local/etc/dovecot/rspamd/spam-delivery.sieve
}
}
# Existing IMAP COPY feedback scripts remain separate. LMTP fileinto does not
# produce an IMAP COPY event, so automatic filing cannot train the classifier.
Сохранить /usr/local/etc/dovecot/rspamd/spam-delivery.sieve как root:wheel 0644:
require ["fileinto", "mailbox"];
# Exim removes untrusted X-Spam-Flag and adds the local Rspamd result.
# This administrator script is used only for LMTP delivery, not IMAP events.
if header :is "X-Spam-Flag" "YES" {
fileinto :create "Junk";
stop;
}
# Unmarked messages use implicit keep (INBOX in the current configuration).
В конец основного dovecot.conf добавить единственную строку:
!include /usr/local/etc/dovecot/rspamd-spam-delivery.conf
doveconf -n sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/spam-delivery.sieve chown root:wheel /usr/local/etc/dovecot/rspamd/spam-delivery.svbin chmod 0644 /usr/local/etc/dovecot/rspamd/spam-delivery.svbin service dovecot restart service dovecot status
15.8. Сквозная проверка обучения
Использовать отдельный тестовый ящик из уже настроенного домена; войти в него полным адресом через IMAP или SnappyMail. В клиенте папка Junk должна иметь специальный флаг \Junk и может отображаться как «Спам». Если папки нет в списке — обновить список/подписку.
- Проверить доставку обычного письма и локальные заголовки X-Spam-Score/X-Rspamd-Action. Поддельный X-Spam-Flag внешнего отправителя должен быть очищен и заменён результатом сервера.
- Отправить безвредное тестовое письмо себе. Переместить INBOX → Junk. В worker.log должен появиться feedback_processed с label=spam и status=learned, а событие перейти из pending в processed.
- Вернуть то же письмо Junk → INBOX: появится новое событие ham. Для проверки повторного исправления можно пройти spam → ham → spam → ham.
- Повтор той же копии с тем же классом должен иметь status=duplicate, не увеличивая вес примера повторно.
- Junk → Trash, изменение флагов и добавление письма сразу в папку не должны создавать обучающие события.
- Если включена сортировка, новая доставка в Junk должна обходиться без обратной связи; ручной возврат такого письма во Входящие должен дать ровно один ham-event.
- Отдельно проверить, что обычная почта доставляется при недоступном Rspamd, а ClamAV по-прежнему отклоняет EICAR. Остановку служб для этой проверки выполнять в согласованное окно или на тестовом экземпляре.
Для тестов, которые не должны обучать рабочую Bayes-базу, нужен отдельный экземпляр Rspamd с изолированной Redis-базой. Примеры обычных пользователей в тестовую базу не копировать. Синтетические события после теста удаляют только вместе с явно выделенным тестовым ящиком/изолированной очередью.
service rspamd_feedback status rspamd-feedback list rspamd-feedback list --state processed rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat tail -50 /opt/mail/log/rspamd-feedback/worker.log exim -bpc
Просмотр одного события, подставив его фактический ID из list:
rspamd-feedback show ID --state processed
15.9. Ограничения, очередь и ошибки
Очередь pending ограничена 1024 событиями и 512 MiB; одно письмо — не более 50 MiB. Сборщик сохраняет резерв свободного места 1 GiB плюс максимальное письмо и метаданные. Архив processed содержит root-only снимок и сохранённый исходный event; архив сам не очищается. После 1024 записей выдаётся предупреждение, при 512 MiB worker приостанавливает дальнейшую обработку до обслуживания архива. Нельзя без разбора удалять журнал processed: он нужен для подавления повторов и сохранения неопределённых результатов.
При controller_unavailable проверяются служба Rspamd, loopback 11334, файл controller.password и права. Пока контроллер заведомо недоступен, события остаются pending и могут быть обработаны после восстановления. Status learn_uncertain означает, что запрос уже мог изменить Bayes: автоматического повтора нет; последующие события того же содержимого изолируются для ручного разбора. Не переименовывать такие события обратно в pending и не запускать learn повторно вслепую.
Обратная связь не подтверждает, что класс письма объективно верен: это пользовательский сигнал общей статистической базе. Не выставлять контроллер Rspamd или Redis в интернет и не передавать пароль контроллера Dovecot, PHP либо vmail.
15.10. Отключение без удаления почты и статистики
Остановить только автоматическое обучение:
service rspamd_feedback stop sysrc rspamd_feedback_enable=NO
Оценка Rspamd, доставка и IMAPSieve-сбор событий продолжат работать, уже обученная Bayes-база сохраняется. Пока worker выключен, следить за размером pending.
Чтобы отключить только автоматическую сортировку, убрать из основного dovecot.conf строку !include /usr/local/etc/dovecot/rspamd-spam-delivery.conf, выполнить doveconf -n и service dovecot restart. Обучение по пользовательским перемещениям сохранится.
Для полного отключения обратной связи остановить worker и убрать include rspamd-feedback.conf из dovecot.conf, проверить конфигурацию и перезапустить Dovecot. Для отключения оценки убрать только добавленный блок Rspamd из acl_check_data и spamd_address, проверить exim -bV и перечитать Exim. Если оставляется правило LMTP-сортировки, очистку чужих X-Spam-заголовков необходимо сохранить; проще сначала отключить сортировку. Правила ClamAV, пользовательский каталог и Maildir не удалять. Redis/Rspamd останавливать лишь после исключения зависимых функций.
Источники к настройке Rspamd
- Rspamd: Redis, контроллер, secure_ip и первоначальные настройки
- Rspamd: генерация и проверка хеша пароля
- Dovecot 2.4: IMAPSieve и imapsieve_from
- Exim: Rspamd и defer_ok в ACL
Первичные источники
- FreeBSD Handbook: диски и UFS
- gpart(8)
- Exim: Dovecot authenticator
- Exim: антивирус в ACL
- Dovecot 2.4: Exim SASL
- Dovecot 2.4: passwd-file
- Dovecot: LMTP
- acme.sh: ручной DNS-01
- acme.sh: install-cert и hook
- SnappyMail: администрирование
- Nginx: reverse proxy и проверка upstream TLS
- ClamAV: конфигурация
- FreshClam: ошибки и ограничения CDN
- ClamAV: зеркала баз
- EICAR: стандартный антивирусный тест
- Grafana Alloy: документация