103 Commits

Author SHA1 Message Date
ai be1849681d корретировка README 2026-03-10 11:35:21 +03:00
ai ee953c1b60 typo 2026-03-10 11:27:59 +03:00
ai d5b7405971 обраболтка пустого mail from 2026-03-10 10:34:50 +03:00
ai 0cad04da66 исправление тестов, в связи с изменением <> вокруг email 2026-03-09 11:52:15 +03:00
ai 7157abd56c удаление <> вокруг email, теперь в rspamd адреса уходят чистыми 2026-03-09 11:41:49 +03:00
ai f00cfd4153 корректировка комментариев и пробелов 2026-03-09 11:22:44 +03:00
ai b823f4f0a3 remove dead code 2026-03-07 16:19:23 +03:00
ai 02d6e8fa06 добавлен ключ -version 2026-03-07 14:29:59 +03:00
ai 6c86bbf6b0 fmt 2026-03-06 22:45:31 +03:00
ai 1d9e6b5b33 исправление ошибок парсинга письма 2026-03-06 20:23:39 +03:00
ai 9401266553 fmt 2026-03-06 11:39:33 +03:00
ai 3c5aef25e9 использование обезличенного домена в тестах 2026-03-06 11:38:18 +03:00
ai ebb759cb30 fmt 2026-03-06 10:09:54 +03:00
ai fd76bb19f8 печать в секундах 2026-03-06 02:25:12 +03:00
ai 8127c512ce тип Duration и метод Append 2026-03-06 02:20:47 +03:00
ai 8373f81f24 поддержка Appender при печати 2026-03-06 02:19:24 +03:00
ai fcaa4b1a9d float32 -> float64 2026-03-06 01:38:35 +03:00
ai 53ec580c99 поддержка float, увеличение буфера 2026-03-06 01:31:05 +03:00
ai 6f36f283e2 добавлены переключатели языка 2026-03-06 01:04:50 +03:00
ai ccb1ccc20e добавлен README на английском 2026-03-06 00:55:46 +03:00
ai ac6fdff1dd изменена ссылка на документацию CommuniGate Pro 2026-03-06 00:54:50 +03:00
ai 0816768341 обновление README 2026-03-05 23:56:54 +03:00
ai 2add0e8caa добавление бенчмарка 2026-03-05 23:38:22 +03:00
ai a83623e6da исправление тестов 2026-03-05 23:37:44 +03:00
ai 4cf590ef5f perf!: streaming I/O refactoring, full test coverage, and v3.0.0
- Implemented streaming processing for constant memory footprint.
- Optimized memory usage: reduced allocations from 185 to 99.
- Migrated `config.Direction` to typed Enum.
- Added comprehensive test suite for config, cgp, rspamc and internal logic.
- Cleaned up loop protection and action handlers.
2026-03-05 23:19:40 +03:00
ai 37fa8a3d09 начало двуязычного README 2026-03-05 23:00:32 +03:00
ai 6686a722b8 тесты для utils 2026-03-05 22:33:03 +03:00
ai 9ea6e3a535 изменение домена 2026-03-05 22:32:14 +03:00
ai 938ff7d8c9 добавлена функция Bytes2string 2026-03-05 22:31:28 +03:00
ai d3c34f4414 обновление зависимостей 2026-03-05 22:30:23 +03:00
ai f5d60164be bench 2026-03-05 22:29:31 +03:00
ai ca99deb5e1 оптимизация stdoutFd 2026-03-01 11:00:19 +03:00
ai b074a371f5 рефакторинг 2026-03-01 10:59:13 +03:00
ai 40ba8f6878 изменение комментария 2026-02-25 01:54:08 +03:00
ai 2b8d64c89e рефакторинг, пересечение имён с константой 2026-02-25 01:53:20 +03:00
ai 659d1bf531 рефакторинг 2026-02-23 13:58:31 +03:00
ai 9a4226ceea добавлена функция extractAngle, рефакторинг обработки ошибок 2026-02-23 13:57:02 +03:00
ai aed98832e4 рефакторинг, проверка приведения типов 2026-02-23 13:55:27 +03:00
ai ae3586e0cc проверка приведения типов, оптимизация 2026-02-23 13:52:40 +03:00
ai 3ef6db6a7d переписано на параллельное выполнение запросов 2026-02-23 13:50:47 +03:00
ai c5a5acbd4f обновление версии go и зависимостей 2026-02-23 13:48:54 +03:00
ai 489783d652 обновление лицензии 2026-02-23 13:48:02 +03:00
ai 7462a3de80 обновление зависимостей 2025-04-14 21:29:02 +03:00
ai e0d11b742f обработан тип сообщений ICAL 2025-01-30 10:57:53 +03:00
ai f896c4ad0e переход на стандартный encoding/json по результатам тестирования производительности 2025-01-25 22:01:23 +03:00
ai eabe7b6eb0 форматирование 2025-01-25 14:40:00 +03:00
ai 3ebf21580a реализована поддержка direction для action и SYMBOLs 2025-01-05 16:53:23 +03:00
ai c0550a18fe обновление зависимостей 2025-01-04 15:00:09 +03:00
ai f23bd04dbe доработка README 2024-12-21 17:30:38 +03:00
ai 768cfd4e61 обработка actions и SYMBOLs при outbound 2024-12-21 17:18:30 +03:00
ai 265eacb356 доработка README 2024-12-19 20:03:35 +03:00
ai 27d5096d56 добавлена обработка исходящей почты 2024-12-19 16:58:47 +03:00
ai a5bb18126d доработка README 2024-12-19 12:17:05 +03:00
ai 1bcab2359d доработка README 2024-12-19 12:07:41 +03:00
ai 23e91eaee7 доработка README 2024-12-19 12:05:44 +03:00
ai 82618ee86d доработка README 2024-12-19 11:48:53 +03:00
ai 18bcfbe69e добавлен ключ командной строки debug 2024-12-19 10:16:08 +03:00
ai bb4e1a6fb8 доработка README 2024-12-13 10:43:32 +03:00
ai 88dce405fc доработка README 2024-12-12 23:32:50 +03:00
ai eec1effcb2 доработка README 2024-12-12 23:20:32 +03:00
ai 4aec643190 доработка README 2024-12-10 11:35:44 +03:00
ai 7b5ee82552 добавлено информационное сообщение при discard 2024-12-06 19:41:29 +03:00
ai 6711b3e93f исправление: отсутствовало закрытие файла 2024-12-05 23:41:00 +03:00
ai 36e44058f8 поддержка Hostname протокола Rspamd 2024-12-05 23:39:42 +03:00
ai fc83af2612 форматирование 2024-12-05 00:38:55 +03:00
ai 12be6f9542 исправлено название символа 2024-12-05 00:28:47 +03:00
ai cbf3d3c3ea обновление README 2024-12-02 13:28:40 +03:00
ai 7e6092c4b1 документирование конфигурационного файла 2024-12-02 13:20:08 +03:00
ai 12fbcdd400 поддержка Helo протокола Rspamd 2024-12-02 13:17:25 +03:00
ai b6c8138cc5 maps examples 2024-12-01 09:30:50 +03:00
ai a46f66a090 RPOP обрабатывается в Rspamd 2024-12-01 00:45:05 +03:00
ai 1bb820fee0 добавлена печать при debug 2024-12-01 00:43:37 +03:00
ai a55162d803 RPOP обрабатывается в Rspamd 2024-12-01 00:37:39 +03:00
ai f70e2f46e1 примеры конфигурационных файлов Rspamd 2024-11-26 21:10:36 +03:00
ai 6e49551391 расширен список внутренних источников данных CGP 2024-11-26 17:25:38 +03:00
ai 2fc0afacac расширен список внутренних источников данных CGP 2024-11-25 14:08:53 +03:00
ai 76428fc6ff исправление: не устанавливался признак аутентифицированности для сообщений, порождённых самим CGP 2024-11-19 00:13:44 +03:00
ai 3403b432af исправление: не устанавливался признак аутентифицированности для сообщений, порождённых самим CGP 2024-11-19 00:11:00 +03:00
ai 13a08b3725 посылка оповещений только локальным rcpts 2024-11-17 23:46:57 +03:00
ai 4ed58b477b удаление ненужного параметра self 2024-11-17 23:44:26 +03:00
ai 5b302def1b замена поля NotifySelf -> NotifyRcpts 2024-11-17 23:42:50 +03:00
ai b4c6d972de добавлены printAuth() и импорт описания символа из Rspamd 2024-11-17 15:36:01 +03:00
ai a2c7940ba1 разработка и оптимизация Rewrite Subject 2024-11-17 00:54:09 +03:00
ai 9ade57dc0c добавлены параметры 2024-11-17 00:50:42 +03:00
ai fe3d70f2fb разработка 2024-11-15 22:21:07 +03:00
ai 229929ee38 разработка и рефакторинг 2024-11-15 22:06:18 +03:00
ai 2c26e3ec35 оптимизация и рефакторинг 2024-11-15 22:03:24 +03:00
ai d1aafd582b добавлен NotifyFrom 2024-11-15 22:01:29 +03:00
ai 0e3bea28ff добавлены функции DiffSlice и NoSpace 2024-11-15 21:59:05 +03:00
ai fb55c39e0b добавлены оповещения 2024-11-15 21:57:13 +03:00
ai 49ac3dfe43 изменены права на файл 2024-11-09 12:40:32 +03:00
ai d14a95d49e изменены права на файл 2024-11-09 12:40:05 +03:00
ai c6772637a6 внутренние функции вынесены в отдельный файл 2024-11-04 20:00:01 +03:00
ai ca22973c25 переработано 2024-11-04 19:18:45 +03:00
ai db2f22658a внутренние функции вынесены в отдельный файл 2024-11-04 19:17:20 +03:00
ai efa8212eab удалена неиспользуемая функция 2024-11-04 19:16:13 +03:00
ai dc48f83f7e изменено название типа 2024-11-04 19:15:16 +03:00
ai 88a9cbcb1c изменено название типа, переупорядочены поля структуры 2024-11-04 19:14:13 +03:00
ai 8fac3092f2 приведена в соответствие go.mod, go.sum 2024-11-04 11:50:22 +03:00
ai 5e25542fec изменена работа с конфигом 2024-11-04 11:49:15 +03:00
ai 281b3a84a5 переработан конфиг 2024-11-04 11:48:32 +03:00
ai 2dbf407442 переработано 2024-11-04 11:46:58 +03:00
ai ec85ae1684 добавлен go get 2024-11-04 11:43:28 +03:00
40 changed files with 3648 additions and 769 deletions
-26
View File
@@ -1,26 +0,0 @@
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
3. The name of the author may not be used to endorse or promote
products derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+27
View File
@@ -0,0 +1,27 @@
Copyright (c) 2021-2026, Andrey Igoshin <ai@vsu.ru>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+105 -14
View File
@@ -1,20 +1,111 @@
[![English](https://img.shields.io/badge/lang-en-blue.svg)](#) [![Russian](https://img.shields.io/badge/lang-ru-lightgrey.svg)](README.ru.md)
Rspamd helper for CommuniGate Pro 5.x, 6.x
## Rspamd helper for CommuniGate Pro 6.x, *version 3.0.0*
Copyright (C) 2017-2024 Andrey Igoshin <ai@vsu.ru>
Version 1.5.7
### Introduction
https://git.vsu.ru/ai/rspamd-cgp
The **rspamd-cgp** *Helper* (external message filter) is designed for spam filtering. It receives messages from *CommuniGate Pro* and passes them to *Rspamd* for processing.<br/><br/>
### Features
* **High Performance:** Starting with version 3.0.0, the *Helper* utilizes *streaming* data processing. This ensures near-constant memory consumption and a significant reduction in allocations, regardless of the processed message size.
* The *Helper* receives messages from *CommuniGate Pro* via the *External Filter Interface* protocol and transmits them to *Rspamd* using the *Rspamd protocol*.
* If a message is received from an authenticated source, the *Helper* passes the `Auth:` header in the *Rspamd protocol*.
* The *Helper* determines the SMTP `HELO/EHLO` identity based on the first `Received:` header of the message and passes the `Helo:` header in the *Rspamd protocol*.
* The *Helper* performs reverse DNS lookup for the message source IP address. If successful, it passes the `Hostname:` header in the *Rspamd protocol*. Lookup results are cached.
* The *Helper* distinguishes between messages received from external sources and those generated by *CommuniGate Pro*. When passing generated messages to *Rspamd*, the *Helper* marks them as originating from a trusted source.
* The *Helper* generates the `X-Junk-Score:` header based on the *action* returned by *Rspamd*. This header is processed by the built-in *Spam Management* rules configured in the *CommuniGate Pro* user interface.
* The *Helper* adds all headers received from *Rspamd* to the message. The decision to include these headers is made on the *Rspamd* side.
* Upon receiving an `action: rewrite subject`, the *Helper* rewrites the message `Subject:` as specified in the *Rspamd* response. This operation triggers the message to pass through the *CommuniGate Pro PIPE*.
* In certain scenarios, to prevent double processing or message looping, the *Helper* adds an `X-Rspamd-Seen:` header to the processed message.
* The *Helper* can perform additional message processing based on its configuration file using data from the *Rspamd* response. Additional processing can be applied to both *actions* and *symbols*. If multiple *actions* and *symbols* match in the configuration file for a specific message, the results are aggregated.
* The *Helper* can process outbound messages. This allows utilizing various *Rspamd* modules to reduce False Positives (FP).
### Installation
The *Helper* configuration file [rspamd-cgp.yml](rspamd-cgp.yml) is located by default in the same directory as the **rspamd-cgp** executable. An alternative configuration file path can be specified via the command line. All available settings are described in detail within the configuration file.
> Below are the *Helper* settings and *Rules* within the *CommuniGate Pro* interface.
#### For Inbound Messages
##### Settings -> General -> Helpers
![](./helper-in.png)
##### Settings -> Mail -> Rules -> RSPAMD_in
![](./rule-in.png)
#### For Outbound Messages
##### Settings -> General -> Helpers
![](./helper-out.png)
##### Settings -> Mail -> Rules -> RSPAMD_out
![](./rule-out.png)
#### Command Line Arguments
```
Usage of rspamd-cgp:
-authserv-id string
Authentication Identifier (default CommuniGate Pro Main Domain)
-host string
Rspamd host to connect (default "localhost:11333")
-mirror-discard
Mirror then discard selected messages
-mirror-to string
Mirror selected messages to email
-timeout duration
Rspamd request timeout (default 15s)
-config string
Set configuration file (default "rspamd-cgp.yml")
-configdump
Perform configuration file dump
-configtest
Perform configuration file test
-debug
Run in debug mode
-outbound
Outbound message flow processing
-version
Print version and exit
```
**config**
Specifies an alternative configuration file.
**configdump**
Outputs the configuration file in a formatted view.
**configtest**
Verifies the syntactic correctness of the configuration file.
**debug**
Outputs the *Rspamd* response (JSON) in a formatted view. Can be used to monitor *symbols* and other data returned by *Rspamd*. The input file must be in the *CommuniGate Pro* queue file format. ***Use only when running from the command line!!!***
**outbound**
Processes the outbound message flow. If outbound messages are sent to external MTAs, spam check headers are not added. The specific messages to be processed in this mode are determined by the *CommuniGate Pro Rule*.
<br/>
### License
BSD License, [LICENSE.md](LICENSE.md)<br/><br/>
### Author
Andrey Igoshin <<ai@vsu.ru>><br/><br/>
### Links
* Repository: <https://git.vsu.ru/ai/rspamd-cgp>
* CommuniGate Pro Website: <https://communigatepro.ru>
* Helper Protocol: <https://doc.communigatepro.ru/russian/development/Helpers.html#Filters>
* Rspamd Website: <https://rspamd.com>
* Rspamd Protocol: <https://rspamd.com/doc/developers/protocol.html>
+114
View File
@@ -0,0 +1,114 @@
[![English](https://img.shields.io/badge/lang-en-lightgrey.svg)](README.md) [![Russian](https://img.shields.io/badge/lang-ru-red.svg)](#)
## Rspamd helper for CommuniGate Pro 6.x, *версия 3.0.0*
### Введение
*Помощник* (внешний фильтр сообщений) **rspamd-cgp** используется для фильтрования спама. Он получает сообщения от *CommuniGate Pro* и передаёт их для обработки в *Rspamd*.<br/><br/>
### Возможности
* **Высокая производительность:** Начиная с версии 3.0.0, *Помощник* использует потоковую (*streaming*) обработку данных. Это позволило добиться практически константного потребления памяти и значительного снижения количества аллокаций, независимо от размера обрабатываемого сообщения.
* *Помощник* получает сообщение от *CommuniGate Pro* по протоколу *Интерфейса Внешнего Фильтра* и передаёт сообщение в *Rspamd*, используя *Rspamd protocol*.
* Если сообщение получено из аутентифицированного источника, *Помощник* передаёт заголовок *Auth:* в *Rspamd protocol*.
* *Помощник* определяет *HELO/EHLO* протокола *SMTP* на основании первого заголовка *Received:* сообщения и передаёт заголовок *Helo:* в *Rspamd protocol*.
* *Помощник* выполняет резольвинг ip-адреса источника сообщения, и, если резольвинг успешен, передаёт заголовок *Hostname:* в *Rspamd protocol*. Результаты резольвинга кешируются.
* *Помощник* различает сообщения, полученные из внешних источников, и сгенерированные *CommuniGate Pro*. При передаче в *Rspamd* сгенерированных сообщений *Помощник* помечает их как полученные из доверенного источника.
* *Помощник* формирует заголовок *X-Junk-Score:* на основании *action*, пришедшего от *Rspamd*. Этот заголовок обрабатывается встроенными правилами *Управление Спамом*, которые настраиваются в пользовательском интерфейсе *CommuniGate Pro*.
* *Помощник* добавляет в сообщение все полученные от *Rspamd* заголовки. Решение о добавлении этих заголовков принимается на стороне *Rspamd*.
* При получении *action: rewrite subject* *Помощник* переписывает *Subject:* сообщения как указано в ответе *Rspamd*. Эта операция вызывает проход сообщения через *CommuniGate Pro PIPE*.
* В ряде случаев, чтобы избежать двойной обработки или зацикливания сообщений, *Помощник* добавляет в обработанное сообщение заголовок *X-Rspamd-Seen:*.
* *Помощник* может выполнять дополнительную обработку сообщений на основании своего конфигурационного файла, используя данные из ответа *Rspamd*. Дополнительная обработка может быть применена к *действиям* и *символам*. Если для конкретного сообщения в конфигурационном файле *Помощника* совпали несколько *действий* и *символов*, для получения итогового результата они суммируются.
* *Помощник* может обрабатывать исходящие сообщения. Это позволяет задействовать ряд модулей *Rspamd* для уменьшения ложных срабатываний (FP).
### Установка
Конфигурационный файл *Помощника* [rspamd-cgp.yml](rspamd-cgp.yml) по умолчанию находится в той же директории, что и исполняемый файл **rspamd-cgp**. При необходимости другое местоположение конфигурационного файла можно указать в командной строке. Возможные настройки *Помощника* подробно описаны в конфигурационном файле.
> Ниже показаны настройки *Помощника* и *Правила* в интерфейсе *CommuniGate Pro*.
#### Для входящих сообщений
##### Установки -> Общее -> Помощники
![](./helper-in.png)
##### Установки -> Почта -> Правила -> RSPAMD_in
![](./rule-in.png)
#### Для исходящих сообщений
##### Установки -> Общее -> Помощники
![](./helper-out.png)
##### Установки -> Почта -> Правила -> RSPAMD_out
![](./rule-out.png)
#### Параметры командной строки
```
Usage of rspamd-cgp:
-config string
Set configuration file (default "rspamd-cgp.yml")
-configdump
Perform configuration file dump
-configtest
Perform configuration file test
-debug
Run in debug mode
-outbound
Outbound message flow processing
-version
Print version and exit
```
**config**
Указывает альтернативный конфигурационный файл.
**configdump**
Выводит конфигурационный файл в форматированном виде.
**configtest**
Проверяет синтаксическую корректность конфигурационного файла.
**debug**
Выводит в форматированном виде ответ *Rspamd* (JSON). Может быть
использован для контроля возвращаемых *Rspamd* *символов* и других данных. Входной файл должен быть в формате файла очереди *CommuniGate Pro*. ***Применять только при запуске из командной строки!!!***
**outbound**
Обрабатывает поток исходящих сообщений. Если исходящие сообщения отправляются на внешние MTA, в них не добавляются заголовки, являющиеся результатом проверки на спам. Какие именно сообщения обрабатываются таким образом определяется *Правилом*
*CommuniGate Pro*.
<br/>
### Лицензия
BSD License, [LICENSE.md](LICENSE.md)<br/><br/>
### Автор
Andrey Igoshin <<ai@vsu.ru>><br/><br/>
### Ссылки
* Репозиторий: <https://git.vsu.ru/ai/rspamd-cgp>
* Сайт CommuniGate Pro: <https://communigatepro.ru>
* Протокол Помощника: <https://doc.communigatepro.ru/russian/development/Helpers.html#Filters>
* Сайт Rspamd: <https://rspamd.com>
* Протокол Rspamd: <https://rspamd.com/doc/developers/protocol.html>
+45 -16
View File
@@ -2,21 +2,50 @@
export GOPATH="${HOME}/src/rspamd-cgp"
# если на целевой ОС не совпадает glibc, то собираем без зависимостей.
# результирующий файл, возможно, получится медленнее и большего размера.
VERSION=$(git describe --tags --always 2>/dev/null || echo "manual")
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# Путь к пакету, где лежат переменные
PKG="git.vsu.ru/ai/rspamd-cgp/config"
LDFLAGS="-X '$PKG.Version=$VERSION' -X '$PKG.Commit=$COMMIT' -X '$PKG.BuildTime=$BUILD_TIME'"
export CGO_ENABLED=0
if [ "$1" == "fmt" ]; then
go fmt $*
elif [ "$1" == "tidy" ]; then
go mod tidy
elif [ "$1" == "update" ]; then
echo "update..."
go get -u ./...
go mod tidy
elif [ "$1" == "vet" ]; then
echo "vet..."
go vet ./...
else
go build
fi
case "$1" in
"fmt")
go fmt ./...
;;
"fix")
go fix -diff ./...
;;
"test")
go test -v ./...
;;
"test-one")
go test -v -run "$2"
;;
"bench")
go test -bench=. -benchmem -run=^#
;;
"pprof")
go test -cpu=1 -bench=BenchmarkRspamc_Scan_RealWork -benchmem -memprofile mem.out -cpuprofile cpu.out
;;
"tidy")
go mod tidy
;;
"update")
echo "Updating dependencies..."
go get -u ./...
go mod tidy
;;
"vet")
echo "Running static analysis..."
go vet ./...
;;
*)
echo "Building version $VERSION..."
go build -ldflags="$LDFLAGS" -o rspamd-cgp
;;
esac
+244 -516
View File
@@ -3,577 +3,305 @@ package cgp
import (
"bufio"
"bytes"
"crypto/sha256"
"fmt"
"io"
"os"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
"unicode"
)
const recvHdr = "Received:"
const seenHdr = "X-Rspamd-Seen:"
const subjHdr = "Subject:"
const submitDir = "Submitted"
type Appender interface {
Append([]byte) []byte
}
var MainDomain string
var reMD *regexp.Regexp
var reSELF *regexp.Regexp
var reSMTP *regexp.Regexp
var protocol int
const (
submitDir = "Submitted"
)
func init() {
reMD = regexp.MustCompile(`^\s+DomainName\s+=\s+([^;]+);`)
reSELF = regexp.MustCompile(`^S (?:<([^>]+)> )?(?:DSN|GROUP|LIST|PBX|PIPE|RULE) \[0\.0\.0\.0\]`)
reSMTP = regexp.MustCompile(`^S (?:<([^>]+)> )?(?:SMTP|HTTPU?|AIRSYNC|XIMSS|IMAP) \[([0-9a-f.:]+)\]`)
var (
protocol int = 4
stdoutFd int
)
err := setMainDomain()
if err != nil {
Putline("* Can not detect Main Domain: %v\n", err)
}
var bufferPool = sync.Pool{
New: func() any { return bytes.NewBuffer(make([]byte, 0, 4096)) },
}
func AddHeader(seq int, headers []string) {
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
hdrs := replaceSpecChars(strings.Join(headers, "\n"))
buf.WriteString(strconv.Itoa(seq))
buf.WriteString(" ADDHEADER \"")
if protocol >= 4 {
Putline("%d ADDHEADER \"%s\" OK\n", seq, hdrs)
} else {
Putline("%d ADDHEADER \"%s\"\n", seq, hdrs)
for i, h := range headers {
replaceSpecCharsBuf(buf, h)
if i < len(headers)-1 {
buf.WriteString("\\e")
}
}
buf.WriteString("\" OK")
res := buf.Bytes()
length := len(res)
if length > 4096 || (length == 4096 && res[length-1] != '\n') {
Failure(seq, 0, fmt.Errorf("AddHeader: result exceeds 4k limit"))
return
}
if length > 0 && res[length-1] != '\n' {
buf.WriteByte('\n')
res = buf.Bytes()
}
syscall.Write(stdoutFd, res)
}
func Discard(seq int, qid int, from string, rcpts []string) {
Putline("* %d [%d]: Action: discard; from %s, rcpts %s\n", seq, qid, from, strings.Join(rcpts, ","))
Putline("%d DISCARD\n", seq)
func Discard(seq int) {
var buf [64]byte
b := strconv.AppendInt(buf[:0], int64(seq), 10)
b = append(b, " DISCARD\n"...)
syscall.Write(stdoutFd, b)
}
func Failure(seq, qid int, err error) {
Putline("* %d [%d]: %s\n", seq, qid, err)
Putline("%d FAILURE\n", seq)
var buf [512]byte
b := buf[:0]
b = append(b, "* "...)
b = strconv.AppendInt(b, int64(seq), 10)
b = append(b, " ["...)
b = strconv.AppendInt(b, int64(qid), 10)
b = append(b, "]: "...)
if err != nil {
b = append(b, err.Error()...)
} else {
b = append(b, "unknown error"...)
}
length := len(b)
if length > 0 && b[length-1] != '\n' {
b = append(b, '\n')
}
syscall.Write(stdoutFd, b)
b = buf[:0]
b = strconv.AppendInt(b, int64(seq), 10)
b = append(b, " FAILURE\n"...)
syscall.Write(stdoutFd, b)
}
// InitStdoutFd инициализирует системный дескриптор для ответов серверу.
// Вызывать в самом начале main().
func InitStdoutFd() error {
rawConn, err := os.Stdout.SyscallConn()
if err != nil {
return err
}
err = rawConn.Control(func(fd uintptr) {
stdoutFd = int(fd)
})
if err != nil {
return err
}
return nil
}
func Intf(seq int, ver string) {
protocol, _ = strconv.Atoi(ver)
Putline("%d INTF %d\n", seq, protocol)
var buf [64]byte
b := strconv.AppendInt(buf[:0], int64(seq), 10)
b = append(b, " INTF "...)
b = append(b, strconv.FormatInt(int64(protocol), 10)...)
b = append(b, '\n')
syscall.Write(stdoutFd, b)
}
func Message(filename string) (from string, rcpts []string, auth string, ip string, qid int, body []byte, seen bool, err error) {
qid, err = strconv.Atoi((filename)[strings.LastIndexByte(filename, '/')+1 : strings.LastIndexByte(filename, '.')])
func MainDomain() (string, error) {
h, err := os.Open("Settings/Main.settings")
if err != nil {
return
}
h, err := os.Open(filename)
if err != nil {
return
return "", err
}
defer h.Close()
var line []byte
var pos int64
m := bufio.NewReader(h)
rd := bufio.NewReader(h)
key := []byte("DomainName")
for {
line, err = m.ReadSlice('\n')
if err != nil {
return
line, err := rd.ReadSlice('\n')
if err != nil && err != io.EOF {
return "", err
}
pos += int64(len(line))
// Ищем вхождение DomainName
idxKey := bytes.Index(line, key)
if idxKey == -1 {
if err == io.EOF {
break
}
continue
}
if string(line) == "\n" {
// Проверяем, что перед ключом стоит разделитель (начало строки, пробел, { или ;)
if idxKey > 0 {
prev := line[idxKey-1]
if prev != ' ' && prev != '\t' && prev != '{' && prev != ';' {
if err == io.EOF {
break
}
continue
}
}
// Ищем '=' после ключа
lineAfterKey := line[idxKey+len(key):]
idxEq := bytes.IndexByte(lineAfterKey, '=')
if idxEq == -1 {
if err == io.EOF {
break
}
continue
}
// Ищем ';' после '='
idxSemi := bytes.IndexByte(lineAfterKey[idxEq:], ';')
if idxSemi == -1 {
if err == io.EOF {
break
}
continue
}
// Извлекаем и чистим значение
rawVal := lineAfterKey[idxEq+1 : idxEq+idxSemi]
cleanVal := bytes.Trim(rawVal, " \t\r\n\"")
if len(cleanVal) > 0 {
return string(cleanVal), nil
}
if err == io.EOF {
break
}
switch line[0] {
case 'P':
s := strings.IndexByte(string(line), '<')
from = string(line[s : s+strings.IndexByte(string(line[s:]), '>')+1])
case 'R':
s := strings.IndexByte(string(line), '<')
rcpts = append(rcpts, string(line[s:s+strings.IndexByte(string(line[s:]), '>')+1]))
case 'S':
if s := reSMTP.FindAllStringSubmatch(string(line), -1); s != nil {
auth = s[0][1]
ip = s[0][2]
} else if s := reSELF.FindAllStringSubmatch(string(line), -1); s != nil {
auth = s[0][1]
ip = "127.2.4.7"
}
}
}
seen, err = isSeen(m)
if err != nil {
return
}
if seen {
return
}
fi, err := h.Stat()
if err != nil {
return
}
_, err = h.Seek(pos, os.SEEK_SET)
if err != nil {
return
}
body = make([]byte, fi.Size()-pos)
n, err := h.Read(body)
if err != nil {
return
}
rcpts = uniqueNonEmptyElementsOf(rcpts)
if from == "" || len(rcpts) == 0 || n < len(body) {
err = fmt.Errorf("cgp.Message() error: from='%s', len(to)=%d, auth='%s' ip='%s', size=%d/%d", from, len(rcpts), auth, ip, len(body), n)
}
return
return "", fmt.Errorf("DomainName not found in settings")
}
func MirrorTo(seq int, qid int, to []string, headers []string, body []byte, mirrorDiscard bool) {
func MirrorTo(seq int, m *Message, to []string, headers []string, discard bool) {
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
if protocol >= 4 {
// SEQ [ADDHEADER "h1\eh2"] [MIRRORTO "rcpt"] {DISCARD|OK}
buf.WriteString(strconv.Itoa(seq))
if len(to) > 0 {
seenHdr, err := makeSeen(body)
if err != nil {
Failure(seq, qid, err)
return
if len(headers) > 0 {
buf.WriteString(" ADDHEADER \"")
for i, h := range headers {
replaceSpecCharsBuf(buf, h)
if i < len(headers)-1 {
buf.WriteString("\\e")
}
headers = append(headers, seenHdr)
hdrs := replaceSpecChars(strings.Join(headers, "\n"))
mirrorTo := []string{}
for _, m := range to {
mirrorTo = append(mirrorTo, fmt.Sprintf("MIRRORTO \"%s\"", m))
}
if mirrorDiscard {
Putline("%d ADDHEADER \"%s\" %s DISCARD\n", seq, hdrs, strings.Join(mirrorTo, " "))
} else {
Putline("%d ADDHEADER \"%s\" %s OK\n", seq, hdrs, strings.Join(mirrorTo, " "))
}
} else {
hdrs := replaceSpecChars(strings.Join(headers, "\n"))
Putline("%d ADDHEADER \"%s\" OK\n", seq, hdrs)
}
} else {
hdrs := replaceSpecChars(strings.Join(headers, "\n"))
Putline("%d ADDHEADER \"%s\"\n", seq, hdrs)
buf.WriteString("\"")
}
for _, rcpt := range to {
buf.WriteString(" MIRRORTO \"")
replaceSpecCharsBuf(buf, rcpt)
buf.WriteString("\"")
}
if discard {
buf.WriteString(" DISCARD")
} else {
buf.WriteString(" OK")
}
res := buf.Bytes()
if len(res) > 4096 {
Failure(seq, m.QID, fmt.Errorf("MirrorTo: result exceeds 4k limit (%d bytes)", len(res)))
return
}
if len(res) > 0 && res[len(res)-1] != '\n' {
buf.WriteByte('\n')
res = buf.Bytes()
}
syscall.Write(stdoutFd, res)
}
func Ok(seq int) {
Putline("%d OK\n", seq)
var buf [64]byte
b := strconv.AppendInt(buf[:0], int64(seq), 10)
b = append(b, " OK\n"...)
syscall.Write(stdoutFd, b)
}
func OkSeen(seq, qid int) {
Putline("* %d [%d]: Already seen by Rspamd\n", seq, qid)
Putline("%d OK\n", seq)
var buf [128]byte
b := buf[:0]
b = append(b, "* "...)
b = strconv.AppendInt(b, int64(seq), 10)
b = append(b, " ["...)
b = strconv.AppendInt(b, int64(qid), 10)
b = append(b, "]: Already seen by Rspamd\n"...)
syscall.Write(stdoutFd, b)
Ok(seq)
}
func Putline(format string, a ...interface{}) {
s := fmt.Sprintf(format, a...)
syscall.Write(int(os.Stdout.Fd()), []byte(s))
func Putline(a ...any) {
var stackBuf [256]byte
res := stackBuf[:0]
for _, arg := range a {
switch v := arg.(type) {
case string:
res = append(res, v...)
case int:
res = strconv.AppendInt(res, int64(v), 10)
case int64:
res = strconv.AppendInt(res, v, 10)
case float64:
res = strconv.AppendFloat(res, v, 'f', 2, 64)
case Appender:
res = v.Append(res)
case float32:
res = strconv.AppendFloat(res, float64(v), 'f', 2, 64)
case error:
if v != nil {
res = append(res, v.Error()...)
}
case []byte:
res = append(res, v...)
default:
res = append(res, fmt.Sprint(v)...)
}
}
length := len(res)
if length > 4096 || (length == 4096 && length > 0 && res[length-1] != '\n') {
os.Stderr.WriteString("Putline: result exceeds 4k limit\n")
return
}
if length > 0 && res[length-1] != '\n' {
res = append(res, '\n')
}
syscall.Write(stdoutFd, res)
}
func Reject(seq int) {
Putline("%d REJECT Try again later\n", seq)
}
func RewriteSubject(seq int, headers []string, subject string, qid int, from string, rcpts []string, body []byte) {
var err error
var firstRecv bool = true
var line []byte
var m *bufio.Reader
var hdr string
filename := fmt.Sprintf("%s/%drs.sub", submitDir, qid)
filetemp := strings.Replace(filename, "sub", "tmp", 1)
fh, err := os.Create(filetemp)
if err != nil {
goto fin
}
defer fh.Close()
_, err = fh.WriteString(strings.Join([]string{"Return-Path: ", from, "\n"}, ""))
if err != nil {
goto fin
}
for _, rcpt := range rcpts {
_, err = fh.WriteString(strings.Join([]string{"Envelope-To: ", rcpt, "\n"}, ""))
if err != nil {
goto fin
}
}
for _, hdr = range headers {
_, err = fh.WriteString(hdr)
if err != nil {
goto fin
}
}
m = bufio.NewReader(bytes.NewReader(body))
for {
hdr, err = getHeader(m)
if err == io.EOF {
err = nil
if len(hdr) == 0 {
break
}
}
if err != nil {
goto fin
}
if hdr == "\n" {
// конец заголовка
_, err = fh.WriteString(hdr)
if err != nil {
goto fin
}
break
}
if firstRecv && strings.HasPrefix(hdr, recvHdr) {
sum := fmt.Sprintf("%x", sha256.Sum224([]byte(nospace(hdr))))
_, err = fh.WriteString(strings.Join([]string{seenHdr, " ", sum, "\n"}, ""))
if err != nil {
goto fin
}
_, err = fh.WriteString(hdr)
if err != nil {
goto fin
}
firstRecv = false
continue
}
if strings.HasPrefix(hdr, subjHdr) {
_, err = fh.WriteString(strings.Join([]string{subjHdr, " ", subject, "\n"}, ""))
if err != nil {
goto fin
}
continue
}
_, err = fh.WriteString(hdr)
if err != nil {
goto fin
}
}
for {
line, err = m.ReadSlice('\n')
if err == io.EOF {
err = nil
break
}
if err != nil {
goto fin
}
_, err = fh.Write(line)
if err != nil {
goto fin
}
}
if err = fh.Close(); err != nil {
goto fin
}
err = os.Rename(filetemp, filename)
fin:
if err != nil {
Failure(seq, qid, err)
} else {
Discard(seq, qid, from, rcpts)
}
}
func getHeader(m *bufio.Reader) (hdr string, err error) {
var c byte
var line []byte
var b strings.Builder
b.Grow(384)
for {
c, err = m.ReadByte()
if err == io.EOF {
if c == 0 {
break
} else {
err = nil
}
}
if err != nil {
return
}
if b.Len() == 0 {
if c == ' ' || c == '\t' {
err = m.UnreadByte()
if err != nil {
return
}
err = fmt.Errorf("bad header")
return
} else if c == '\n' {
b.WriteByte(c)
break
} else {
b.WriteByte(c)
line, err = m.ReadSlice('\n')
if err == io.EOF {
err = nil
}
if err != nil {
return
}
b.Write(line)
}
} else {
if c == ' ' || c == '\t' {
b.WriteByte(c)
line, err = m.ReadSlice('\n')
if err == io.EOF {
err = nil
}
if err != nil {
return
}
b.Write(line)
} else {
err = m.UnreadByte()
if err != nil {
return
}
break
}
}
}
hdr = b.String()
return
}
func isSeen(m *bufio.Reader) (seen bool, err error) {
var found bool
var seenSum string
var hdr string
for {
hdr, err = getHeader(m)
if err == io.EOF {
err = nil
if len(hdr) == 0 {
break
}
}
if err != nil {
return
}
if hdr == "\n" {
// конец RFC5322 заголовка
break
}
if !found {
if seenSum, found = strings.CutPrefix(hdr, seenHdr); found {
seenSum = strings.TrimSpace(seenSum)
}
} else if strings.HasPrefix(hdr, recvHdr) {
sum := fmt.Sprintf("%x", sha256.Sum224([]byte(nospace(hdr))))
if seenSum == sum {
seen = true
}
break
}
}
return
}
func makeSeen(body []byte) (seenhdr string, err error) {
var hdr string
m := bufio.NewReader(bytes.NewReader(body))
for {
hdr, err = getHeader(m)
if err == io.EOF {
err = nil
if len(hdr) == 0 {
break
}
}
if err != nil {
return
}
if hdr == "\n" {
break
}
if strings.HasPrefix(hdr, recvHdr) {
seenhdr = fmt.Sprintf("%s %x", seenHdr, sha256.Sum224([]byte(nospace(hdr))))
break
}
}
return
}
func nospace(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, c := range s {
if !unicode.IsSpace(c) {
b.WriteRune(c)
}
}
return b.String()
}
func replaceSpecChars(msg string) string {
var sb strings.Builder
sb.Grow(len(msg) + 128)
for _, symbol := range msg {
switch symbol {
case rune('\\'):
// replace \ -> \\ (CGP backslash)
sb.WriteString("\\\\")
case 0x0000:
fallthrough
case rune('\r'):
continue
case rune('\n'):
// replace \n -> \\e (CGP End-of-Line)
sb.WriteString("\\e")
case rune('\t'):
// replace \t -> \\t (CGP Tab)
sb.WriteString("\\t")
case rune('"'):
// replace \" -> \\" (CGP quote)
sb.WriteString("\\\"")
default:
sb.WriteRune(symbol)
}
}
return sb.String()
}
func setMainDomain() (err error) {
h, err := os.Open("Settings/Main.settings")
if err != nil {
return
}
defer h.Close()
var line []byte
for m := bufio.NewReader(h); ; {
line, err = m.ReadSlice('\n')
if err != nil {
return
}
if s := reMD.FindAllStringSubmatch(string(line), -1); s != nil {
MainDomain = s[0][1]
break
}
}
return
}
func uniqueNonEmptyElementsOf(s []string) []string {
unique := make(map[string]bool, len(s))
us := make([]string, len(unique))
for _, elem := range s {
if len(elem) != 0 {
if !unique[elem] {
us = append(us, elem)
unique[elem] = true
}
}
}
return us
var buf [64]byte
b := strconv.AppendInt(buf[:0], int64(seq), 10)
b = append(b, " REJECT Try again later\n"...)
syscall.Write(stdoutFd, b)
}
+141
View File
@@ -0,0 +1,141 @@
package cgp
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMainDomain(t *testing.T) {
tmpDir := t.TempDir()
err := os.MkdirAll(filepath.Join(tmpDir, "Settings"), 0755)
if err != nil {
t.Fatal(err)
}
// Тестируем разные варианты написания в Main.settings
content := []byte(`
OtherKey = 123;
DomainName = "relay1.domain.name" ;
UnquotedDomain = domain.name;
# CommentedDomain = ignore.me;
`)
settingsPath := filepath.Join(tmpDir, "Settings", "Main.settings")
if err := os.WriteFile(settingsPath, content, 0644); err != nil {
t.Fatal(err)
}
// Подменяем рабочую директорию
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
t.Run("Extract Quoted Domain", func(t *testing.T) {
got, err := MainDomain()
if err != nil {
t.Fatalf("MainDomain failed: %v", err)
}
if got != "relay1.domain.name" {
t.Errorf("Got %q, want %q", got, "relay1.domain.name")
}
})
}
func TestProtocolCommands(t *testing.T) {
// Создаем pipe, чтобы перехватить то, что функции пишут в stdoutFd
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
// Сохраняем старый дескриптор и подменяем на наш pipe
oldFd := stdoutFd
stdoutFd = int(w.Fd())
defer func() { stdoutFd = oldFd }()
tests := []struct {
name string
fn func()
want string
}{
{
name: "Ok command",
fn: func() { Ok(123) },
want: "123 OK\n",
},
{
name: "Discard command",
fn: func() { Discard(456) },
want: "456 DISCARD\n",
},
{
name: "Reject command",
fn: func() { Reject(789) },
want: "789 REJECT Try again later\n",
},
{
name: "Failure command",
fn: func() { Failure(10, 200, fmt.Errorf("test error")) },
want: "* 10 [200]: test error\n10 FAILURE\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.fn()
buf := make([]byte, 256)
n, _ := r.Read(buf)
got := string(buf[:n])
if got != tt.want {
t.Errorf("Got %q, want %q", got, tt.want)
}
})
}
}
func TestAddHeader_ProtocolHandling(t *testing.T) {
r, w, _ := os.Pipe()
oldFd := stdoutFd
stdoutFd = int(w.Fd())
defer func() { stdoutFd = oldFd }()
t.Run("Protocol v4 with OK", func(t *testing.T) {
protocol = 4
AddHeader(1, []string{"X-Test: True"})
buf := make([]byte, 256)
n, _ := r.Read(buf)
got := string(buf[:n])
if !strings.Contains(got, "\" OK\n") {
t.Errorf("Protocol v4 should append OK, got: %q", got)
}
})
}
func TestPutline(t *testing.T) {
r, w, _ := os.Pipe()
oldFd := stdoutFd
stdoutFd = int(w.Fd())
defer func() { stdoutFd = oldFd }()
t.Run("Variadic mixed types", func(t *testing.T) {
Putline("* ", 1, " error: ", fmt.Errorf("fail"))
buf := make([]byte, 256)
n, _ := r.Read(buf)
got := string(buf[:n])
want := "* 1 error: fail\n"
if got != want {
t.Errorf("Putline mismatch. Got %q, want %q", got, want)
}
})
}
+206
View File
@@ -0,0 +1,206 @@
package cgp
import (
"context"
"net"
"net/netip"
"strings"
"time"
"github.com/maypok86/otter"
)
func filterNames(names []string) []string {
fqdn := make([]string, 0, len(names))
nonfqdn := make([]string, 0, len(names))
for _, name := range names {
if IsValidDomain(name) {
fqdn = append(fqdn, name)
} else {
nonfqdn = append(nonfqdn, name)
}
}
if len(fqdn) > 0 {
return fqdn
} else {
return nonfqdn
}
}
func findLongerItem(items []string) (res string) {
if len(items) > 0 {
var maxlen = 0
var pos int
for p, item := range items {
if len(item) > maxlen {
maxlen = len(item)
pos = p
}
}
res = items[pos]
}
return
}
func findShorterItem(items []string) (res string) {
if len(items) > 0 {
var maxlen = len(items[0])
var pos int
for p, item := range items {
if len(item) < maxlen {
maxlen = len(item)
pos = p
}
}
res = items[pos]
}
return
}
var getHostname = func() func(addr string) (hostname string) {
cache, _ := otter.MustBuilder[netip.Addr, string](1000).
CollectStats().
Cost(func(key netip.Addr, value string) uint32 {
return 1
}).
WithVariableTTL().
Build()
return func(addr string) (hostname string) {
ipAddr, err := netip.ParseAddr(addr)
if err != nil {
return
}
hostname, ok := cache.Get(ipAddr)
if !ok {
hostname = lookupAddr(ipAddr)
if len(hostname) > 0 {
cache.Set(ipAddr, hostname, time.Hour)
} else {
cache.Set(ipAddr, hostname, 5*time.Minute)
}
}
return
}
}()
func IsValidDomain(domain string) bool {
if len(domain) == 0 || len(domain) > 253 {
return false
}
// Fast path: IP-адреса (v4 и v6) обычно начинаются с цифры или двоеточия.
// Домены по RFC могут начинаться с цифры, но это редкость.
// Если первый символ - цифра или ':', проверяем, не IP ли это.
first := domain[0]
if (first >= '0' && first <= '9') || first == ':' {
if _, err := netip.ParseAddr(strings.TrimSuffix(domain, ".")); err == nil {
return false // Это чистый IP
}
}
domain = strings.TrimSuffix(domain, ".")
lastDot := -1
hasDot := false
for i := 0; i < len(domain); i++ {
c := domain[i]
if c == '.' {
labelLen := i - lastDot - 1
// Пустые метки (..) или дефис по краям метки недопустимы
if labelLen == 0 || labelLen > 63 || domain[lastDot+1] == '-' || domain[i-1] == '-' {
return false
}
lastDot = i
hasDot = true
continue
}
// Допустимые символы: [a-zA-Z0-9_-]
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
// Проверка последней метки (TLD)
finalLabelLen := len(domain) - lastDot - 1
if finalLabelLen == 0 || finalLabelLen > 63 || domain[lastDot+1] == '-' || domain[len(domain)-1] == '-' {
return false
}
return hasDot
}
func lookupAddr(ipAddr netip.Addr) (hostname string) {
r := &net.Resolver{PreferGo: true}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
names, err := r.LookupAddr(ctx, ipAddr.String())
if err != nil {
return
}
if len(names) == 1 {
hostname = strings.TrimSuffix(names[0], ".")
return
}
type result struct{ name string }
ch := make(chan result, len(names))
for _, name := range names {
go func(name string) {
ips, err := r.LookupHost(ctx, name)
if err != nil {
ch <- result{}
return
}
for _, ip := range ips {
ipTest, err := netip.ParseAddr(ip)
if err != nil {
continue
}
if ipTest == ipAddr {
ch <- result{name}
return
}
}
ch <- result{}
}(name)
}
found := make([]string, 0, len(names))
for range names {
if r := <-ch; r.name != "" {
found = append(found, r.name)
}
}
switch len(found) {
case 0:
// если ни один name не имеет корректного прямого резольвинга
// в исходный ip, выбираем самый длинный.
hostname = findLongerItem(filterNames(names))
case 1:
hostname = found[0]
default:
// если несколько name имеют корректный прямой резольвинг
// в исходный ip, выбираем самый короткий.
hostname = findShorterItem(filterNames(found))
}
hostname = strings.TrimSuffix(hostname, ".")
return
}
+50
View File
@@ -0,0 +1,50 @@
package cgp
import (
"testing"
)
func TestHostname_Logic(t *testing.T) {
t.Run("FilterNames", func(t *testing.T) {
input := []string{"host", "mail.domain.name", "localhost", "deep.sub.domain.com"}
// Должны остаться только те, что проходят IsValidDomain (с точками)
got := filterNames(input)
if len(got) != 3 { // mail.domain.name, localhost (если rx пропустит), deep...
// Зависит от вашего regex. IsValidDomain требует хотя бы одну точку.
t.Logf("Filtered names: %v", got)
}
})
t.Run("FindShorterItem", func(t *testing.T) {
input := []string{"very-long-hostname.example.com", "short.com", "medium.example.com"}
want := "short.com"
if got := findShorterItem(input); got != want {
t.Errorf("findShorterItem() = %v, want %v", got, want)
}
})
t.Run("FindLongerItem", func(t *testing.T) {
input := []string{"a.ru", "abc.ru", "ab.ru"}
want := "abc.ru"
if got := findLongerItem(input); got != want {
t.Errorf("findLongerItem() = %v, want %v", got, want)
}
})
t.Run("IsValidDomain", func(t *testing.T) {
tests := []struct {
dom string
want bool
}{
{"domain.name", true},
{"mail.domain.name.", true}, // с точкой в конце
{"internal-host", false}, // нет точек
{"1.2.3.4", false}, // IP не должен быть доменом в этой логике
}
for _, tt := range tests {
if got := IsValidDomain(tt.dom); got != tt.want {
t.Errorf("IsValidDomain(%q) = %v, want %v", tt.dom, got, tt.want)
}
}
})
}
+115
View File
@@ -0,0 +1,115 @@
package cgp
import (
"bufio"
"bytes"
"fmt"
"strings"
"unicode/utf8"
)
func extractAngle(line []byte) (string, bool) {
s := strings.IndexByte(string(line), '<')
if s < 0 {
return "", false
}
e := strings.IndexByte(string(line[s:]), '>')
if e < 0 {
return "", false
}
return string(line[s+1 : s+e]), true
}
func getHeader(m *bufio.Reader, buf *bytes.Buffer) error {
for {
line, err := m.ReadSlice('\n')
if err != nil && err != bufio.ErrBufferFull {
if len(line) > 0 {
buf.Write(line)
}
return err
}
buf.Write(line)
if err == bufio.ErrBufferFull {
if buf.Len() > 64*1024 {
return fmt.Errorf("header too long")
}
continue
}
if isHeaderEnd(line) {
return nil
}
next, err := m.Peek(1)
if err != nil {
return err
}
if next[0] != ' ' && next[0] != '\t' {
return nil
}
}
}
func getHelo(hdr []byte) string {
if !bytes.HasPrefix(hdr, []byte("Received: from ")) {
return ""
}
data := hdr[15:]
// Received: from muus52.sndsy.ru ([185.235.30.52] verified)
if idxOpen := bytes.Index(data, []byte(" ([")); idxOpen > 0 {
if bytes.Contains(data[idxOpen:], []byte(" verified)")) {
return string(bytes.TrimSpace(data[:idxOpen]))
}
}
// Received: from [77.83.39.182] (HELO poseidonms.com)
// Received: from [10.19.5.40] (account test@domain.name HELO test.domain.name)
if idxHelo := bytes.Index(data, []byte("HELO ")); idxHelo > 0 {
prevChar := data[idxHelo-1]
if prevChar == ' ' || prevChar == '(' {
remaining := data[idxHelo+5:]
if end := bytes.IndexByte(remaining, ')'); end != -1 {
return string(bytes.TrimSpace(remaining[:end]))
}
}
}
return ""
}
func putBuffer(buf *bytes.Buffer) {
if buf.Cap() > 4096 {
return
}
buf.Reset()
bufferPool.Put(buf)
}
func replaceSpecCharsBuf(buf *bytes.Buffer, s string) {
for _, r := range s {
switch r {
case '\\':
buf.WriteString("\\\\")
case '\n':
buf.WriteString("\\e")
case '\t':
buf.WriteString("\\t")
case '"':
buf.WriteString("\\\"")
case '\r', 0x00:
continue
default:
if r < utf8.RuneSelf {
buf.WriteByte(byte(r))
} else {
buf.WriteRune(r)
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package cgp
import (
"bufio"
"bytes"
"strings"
"testing"
)
func TestGetHeader_Folding(t *testing.T) {
// Проверяем корректность сборки многострочных заголовков (RFC folding)
raw := "Subject: This is a very long\n\t subject line\nNext-Header: value\n"
rd := bufio.NewReader(strings.NewReader(raw))
buf := new(bytes.Buffer)
err := getHeader(rd, buf)
if err != nil {
t.Fatalf("getHeader failed: %v", err)
}
got := buf.String()
// Должен захватить обе строки, так как вторая начинается с табуляции
if !strings.Contains(got, "subject line") {
t.Errorf("Header folding failed. Got: %q", got)
}
if strings.Contains(got, "Next-Header") {
t.Error("getHeader read too far into the next header")
}
}
func TestGetHelo_Variants(t *testing.T) {
tests := []struct {
name string
hdr string
want string
}{
{"Standard verified", "Received: from mail.domain.name ([1.2.3.4] verified)\n", "mail.domain.name"},
{"HELO in brackets", "Received: from [1.2.3.4] (HELO poseidonms.com)\n", "poseidonms.com"},
{"Account and HELO", "Received: from [10.19.5.40] (account test@domain.name HELO test.domain.name)\n", "test.domain.name"},
{"Malformed", "Received: from broken (", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getHelo([]byte(tt.hdr)); got != tt.want {
t.Errorf("getHelo() = %q, want %q", got, tt.want)
}
})
}
}
func TestReplaceSpecCharsBuf(t *testing.T) {
buf := new(bytes.Buffer)
input := "Path\\To\nFile\t\"Name\"\r"
// \ -> \\, \n -> \e, \t -> \t, " -> \", \r -> skip
expected := "Path\\\\To\\eFile\\t\\\"Name\\\""
replaceSpecCharsBuf(buf, input)
if buf.String() != expected {
t.Errorf("replaceSpecCharsBuf failed.\nGot: %q\nWant: %q", buf.String(), expected)
}
}
func TestIsHeaderEnd(t *testing.T) {
if !isHeaderEnd([]byte("\n")) {
t.Error("Failed to detect LF as header end")
}
if !isHeaderEnd([]byte("\r\n")) {
t.Error("Failed to detect CRLF as header end")
}
if isHeaderEnd([]byte("Subject: \n")) {
t.Error("False positive for header end")
}
}
+475
View File
@@ -0,0 +1,475 @@
package cgp
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"
"github.com/cespare/xxhash/v2"
"git.vsu.ru/ai/rspamd-cgp/utils"
)
type Message struct {
File *os.File
QID int
From string
Rcpts []string
Auth string
IP string
Helo string
Hostname string
HdrPos int64 // Смещение до RFC заголовков (после конверта)
BodyPos int64 // Смещение до RFC тела (после заголовков)
Size int64 // Общий размер файла
seen bool
extReceived []byte
}
var (
readerPool = sync.Pool{
New: func() any { return bufio.NewReaderSize(nil, 4096) },
}
writerPool = sync.Pool{
New: func() any { return bufio.NewWriterSize(nil, 4096) },
}
)
var (
bRecvHdr = []byte("Received: ")
bRecvFrom = []byte("Received: from ")
bSeenHdr = []byte("X-Rspamd-Seen: ")
bSubjHdr = []byte("Subject: ")
)
var (
// Тип источника: 1 - Внешний (SMTP/HTTP...), 2 - Внутренний (RULE/LIST...)
sourceModules = map[string]int{
"SMTP": 1, "HTTP": 1, "HTTPU": 1, "AIRSYNC": 1, "IMAP": 1, "RPOP": 1, "XIMSS": 1,
"ALARM": 2, "DSN": 2, "GROUP": 2, "ICAL": 2, "LIST": 2, "LSTM": 2, "LSTO": 2,
"MDN": 2, "PBX": 2, "PIPE": 2, "RULE": 2, "WEBUSER": 2,
}
)
func (m *Message) Close() error {
return m.File.Close()
}
func (m *Message) IsSeen() bool {
return m.seen
}
func (m *Message) MakeSeen() string {
// Если внешних Received не найдено (внутреннее письмо),
// возвращаем пустую строку, чтобы заголовок не добавлялся.
if len(m.extReceived) == 0 {
return ""
}
// bSeenHdr = []byte("X-Rspamd-Seen: ")
// Собираем строку: имя заголовка + хеш от сохраненного оригинала
return string(bSeenHdr) + hashReceived(m.extReceived)
}
func NewMessage(seq int, filename string) (*Message, error) {
// 1. Извлечение QID из имени файла (например, 12345.msg -> 12345)
start := strings.LastIndexByte(filename, '/') + 1
end := strings.LastIndexByte(filename, '.')
if end <= start {
return nil, fmt.Errorf("invalid filename: %s", filename)
}
qid, err := strconv.Atoi(filename[start:end])
if err != nil {
return nil, fmt.Errorf("parse QID failed: %w", err)
}
// 2. Открытие файла оригинала
f, err := os.Open(filename)
if err != nil {
return nil, err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
msg := &Message{
File: f,
QID: qid,
Size: fi.Size(),
}
// 3. Подготовка ридера из пула
rd := getReader(f)
defer putReader(rd)
var pos int64
// 4. Читаем Конверт (Envelope)
for {
line, err := rd.ReadSlice('\n')
if err != nil {
if err == io.EOF {
err = fmt.Errorf("unexpected end of envelope")
}
f.Close()
return nil, err
}
pos += int64(len(line))
if isHeaderEnd(line) {
break
}
// Парсинг параметров конверта
switch line[0] {
case 'P': // Return-Path
if v, ok := extractAngle(line); ok {
msg.From = v
}
case 'R': // Envelope-To
if v, ok := extractAngle(line); ok {
msg.Rcpts = append(msg.Rcpts, v)
}
case 'S': // Source/Interface info
parseSource(seq, line, msg)
}
}
msg.HdrPos = pos
// 5. Разбор Заголовков (Headers)
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
var (
seenSum string
seenFound bool
seenProcessed bool
extReceivedSaved bool
)
for {
buf.Reset()
err := getHeader(rd, buf)
hdr := buf.Bytes()
if len(hdr) > 0 {
pos += int64(len(hdr))
if isHeaderEnd(hdr) {
break
}
// А. Проверка существующей метки Seen (Прошлое)
// Доверяем порядку: Метка -> (опционально не-Received) -> Первый Received
if !seenProcessed {
if !seenFound {
if bytes.HasPrefix(hdr, bSeenHdr) {
seenSum = string(bytes.TrimSpace(hdr[len(bSeenHdr):]))
seenFound = true
}
} else if bytes.HasPrefix(hdr, bRecvHdr) {
// Валидируем по первому же встречному Received
if hashReceived(hdr) == seenSum {
msg.seen = true
}
seenProcessed = true
}
}
// Б. Идентификация точки входа (Будущее)
// Нас интересует только первый внешний Received.
if !extReceivedSaved && isExternal(hdr) {
msg.extReceived = bytes.Clone(hdr)
extReceivedSaved = true
msg.Helo = getHelo(hdr)
}
}
if err != nil {
if err == io.EOF {
break
}
f.Close()
return nil, err
}
}
msg.BodyPos = pos
// Очистка списка получателей от теоретических дублей
msg.Rcpts = utils.UniqueSliceElementsNonEmpty(msg.Rcpts)
return msg, nil
}
func (m *Message) PrintMsgInfo() {
fmt.Fprintln(os.Stderr, "from: ", m.From)
fmt.Fprintln(os.Stderr, "rcpts: ", m.Rcpts)
fmt.Fprintln(os.Stderr, "ip: ", m.IP)
fmt.Fprintln(os.Stderr, "helo: ", m.Helo)
fmt.Fprintln(os.Stderr, "hostname:", m.Hostname)
fmt.Fprintln(os.Stderr, "qid: ", m.QID)
if len(m.Auth) > 0 {
fmt.Fprintln(os.Stderr, "auth: ", m.Auth)
} else {
fmt.Fprintln(os.Stderr, "auth: not authenticated")
}
fmt.Fprintln(os.Stderr, "seen: ", m.seen)
fmt.Fprintln(os.Stderr, "")
}
func (m *Message) RewriteSubject(headers []string, subject string) (err error) {
sQid := strconv.Itoa(m.QID)
filetemp := submitDir + "/" + sQid + "rs.tmp"
filename := submitDir + "/" + sQid + "rs.sub"
fh, err := os.Create(filetemp)
if err != nil {
return fmt.Errorf("RewriteSubject: create tmp failed: %w", err)
}
w := getWriter(fh)
success := false
defer func() {
if !success {
putWriter(w)
fh.Close()
os.Remove(filetemp)
}
}()
// 1. Формируем конверт (Submitted требует Return-Path и Envelope-To)
w.WriteString("Return-Path: ")
w.WriteString(m.From)
w.WriteByte('\n')
for _, r := range m.Rcpts {
w.WriteString("Envelope-To: ")
w.WriteString(r)
w.WriteByte('\n')
}
// 2. Метаданные (Seen, Junk-Score, DKIM).
for _, h := range headers {
w.WriteString(h)
w.WriteByte('\n')
}
// 3. Подготовка к чтению оригинальных заголовков
if _, err = m.File.Seek(m.HdrPos, io.SeekStart); err != nil {
return err
}
rd := getReader(m.File)
defer putReader(rd)
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
// 4. Цикл обработки заголовков: копируем всё, заменяя Subject
for {
buf.Reset()
if err = getHeader(rd, buf); err != nil {
if err == io.EOF {
break
}
return err
}
hdr := buf.Bytes()
if isHeaderEnd(hdr) {
w.Write(hdr)
break
}
// Заменяем оригинальный Subject на новый
if bytes.HasPrefix(hdr, bSubjHdr) {
w.Write(bSubjHdr)
w.WriteString(subject)
w.WriteByte('\n')
continue
}
w.Write(hdr)
}
// 5. Перенос тела письма напрямую через io.Copy
if _, err = m.File.Seek(m.BodyPos, io.SeekStart); err != nil {
return err
}
if _, err = io.Copy(w, m.File); err != nil {
return err
}
if err = w.Flush(); err != nil {
return err
}
if err = fh.Close(); err != nil {
return err
}
if err = os.Rename(filetemp, filename); err != nil {
return err
}
success = true
putWriter(w)
return nil
}
func getReader(r io.Reader) *bufio.Reader {
rd := readerPool.Get().(*bufio.Reader)
rd.Reset(r)
return rd
}
func getWriter(w io.Writer) *bufio.Writer {
bw := writerPool.Get().(*bufio.Writer)
bw.Reset(w)
return bw
}
func hashReceived(b []byte) string {
h := xxhash.New()
start := 0
for i := 0; i < len(b); i++ {
// Нормализация: игнорируем пробелы, табы и переносы строк (folding)
if b[i] <= 32 {
if i > start {
h.Write(b[start:i])
}
start = i + 1
}
}
// Дописываем остаток, если он есть
if start < len(b) {
h.Write(b[start:])
}
// Результат — 64-битное число, приводим к hex (16 символов)
return strconv.FormatUint(h.Sum64(), 16)
}
func isExternal(hdr []byte) bool {
// 1. Проверяем полный префикс.
if !bytes.HasPrefix(hdr, bRecvFrom) {
return false
}
// 2. Смещаемся за "Received: from "
fromVal := hdr[len(bRecvFrom):]
if len(fromVal) == 0 {
return false
}
// 3. Быстрый чек на IP в скобках [IP] (SMTP/Web)
if fromVal[0] == '[' {
return true
}
// 4. Ищем границу идентификатора (до первого пробела)
firstSpace := bytes.IndexByte(fromVal, ' ')
if firstSpace == -1 {
firstSpace = len(fromVal)
}
firstWord := fromVal[:firstSpace]
// 5. Проверка на Email (GROUP/RULE).
// Если во входном идентификаторе нет '@', это сетевой хост.
return len(firstWord) > 0 && !bytes.Contains(firstWord, []byte("@"))
}
func isHeaderEnd(hdr []byte) bool {
return len(hdr) == 1 && hdr[0] == '\n' ||
(len(hdr) == 2 && hdr[0] == '\r' && hdr[1] == '\n')
}
/*
внешние источники: SMTP|HTTPU?|AIRSYNC|IMAP|RPOP|XIMSS
S <test@domain.name> AIRSYNC [83.139.170.75]
S <test@domain.name> HTTP [46.72.226.199]
S SMTP [130.193.65.125]
S <test@domain.name> SMTP [2001:67c:418:2020::21]
S RPOP [81.19.77.161]
внутренние источники: ALARM|DSN|GROUP|ICAL|LIST|LSTM|LSTO|MDN|PBX|PIPE|RULE|WEBUSER
S DSN [0.0.0.0]
S GROUP [0.0.0.0]
S LIST [0.0.0.0]
S LSTM [0.0.0.0]
S <test@domain.name> MDN [0.0.0.0]
S PIPE [0.0.0.0]
S <postmaster@domain.name> RULE [0.0.0.0]
*/
func parseSource(seq int, line []byte, msg *Message) {
// Строка вида: S [<auth> ]MODULE [IP]
data := bytes.TrimSpace(line[1:])
if len(data) == 0 {
return
}
var auth string
// 1. Извлекаем <auth>, если он присутствует
if data[0] == '<' {
endAuth := bytes.IndexByte(data, '>')
if endAuth != -1 {
auth = string(data[1:endAuth])
data = bytes.TrimSpace(data[endAuth+1:])
}
}
// 2. Ищем IP в квадратных скобках в конце строки
openBracket := bytes.LastIndexByte(data, '[')
closeBracket := bytes.LastIndexByte(data, ']')
if openBracket == -1 || closeBracket == -1 || closeBracket <= openBracket {
Putline("* ", seq, " [", msg.QID, "]: unknown S-line format: ", utils.Bytes2string(line))
return
}
moduleBytes := bytes.TrimSpace(data[:openBracket])
ipBytes := data[openBracket+1 : closeBracket]
mType, known := sourceModules[utils.Bytes2string(moduleBytes)]
// 3. Заполнение структуры
if known && mType == 2 && bytes.Equal(ipBytes, []byte("0.0.0.0")) {
// Внутренний источник (trusted)
if auth != "" {
msg.Auth = auth
} else {
msg.Auth = string(moduleBytes) + "@trusted"
}
msg.IP = "127.2.4.7"
} else if known && mType == 1 {
// Внешний источник (SMTP, и т.д.)
msg.Auth = auth
msg.IP = string(ipBytes)
msg.Hostname = getHostname(msg.IP)
} else {
Putline("* ", seq, " [", msg.QID, "]: unknown or mismatched module in S-line: ", string(line))
}
}
func putReader(rd *bufio.Reader) {
rd.Reset(nil)
readerPool.Put(rd)
}
func putWriter(wr *bufio.Writer) {
wr.Reset(nil)
writerPool.Put(wr)
}
+206
View File
@@ -0,0 +1,206 @@
package cgp
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// MessageMock помогает собирать тестовые файлы сообщений CGP в байтовый массив
type MessageMock struct {
Envelope []string
Headers []string
Body string
}
func (m *MessageMock) Render() []byte {
var sb strings.Builder
// 1. Конверт
for _, line := range m.Envelope {
sb.WriteString(line)
sb.WriteByte('\n')
}
sb.WriteByte('\n') // Пустая строка - признак конца конверта
// 2. RFC Заголовки
for _, line := range m.Headers {
sb.WriteString(line)
sb.WriteByte('\n')
}
sb.WriteByte('\n') // Пустая строка - признак конца заголовков
// 3. Тело
sb.WriteString(m.Body)
return []byte(sb.String())
}
// createTestFile создает структуру папок Submitted и пишет туда файл сообщения
func createTestFile(t *testing.T, qid int, content []byte) string {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
filename := filepath.Join(subDir, strconv.Itoa(qid)+".msg")
if err := os.WriteFile(filename, content, 0644); err != nil {
t.Fatal(err)
}
return filename
}
func TestNewMessage_Parsing(t *testing.T) {
rawRecv := "Received: from mail.domain.name ([1.2.3.4] verified)"
mock := &MessageMock{
Envelope: []string{
"P <sender@domain.name>",
"R <rcpt1@domain.name>",
"R <rcpt2@domain.name>",
"S SMTP [1.2.3.4]",
},
Headers: []string{
rawRecv,
"Subject: Test",
"From: sender@domain.name",
},
Body: "Hello world!",
}
content := mock.Render()
qid := 10001
fname := createTestFile(t, qid, content)
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatalf("NewMessage failed: %v", err)
}
defer msg.Close()
// Проверка извлечения данных
if msg.QID != qid {
t.Errorf("QID mismatch: got %d, want %d", msg.QID, qid)
}
if msg.From != "sender@domain.name" {
t.Errorf("From mismatch: got %s", msg.From)
}
if len(msg.Rcpts) != 2 {
t.Errorf("Rcpts count mismatch: got %d", len(msg.Rcpts))
}
if msg.IP != "1.2.3.4" {
t.Errorf("IP mismatch: got %s", msg.IP)
}
if msg.Helo != "mail.domain.name" {
t.Errorf("Helo mismatch: got %s", msg.Helo)
}
// Проверка смещений (HdrPos должен указывать на 'R' в 'Received')
expectedHdrPos := int64(strings.Index(string(content), "Received:"))
if msg.HdrPos != expectedHdrPos {
t.Errorf("HdrPos: got %d, want %d", msg.HdrPos, expectedHdrPos)
}
// Проверка смещения тела (BodyPos после пустой строки после заголовков)
expectedBodyPos := int64(strings.Index(string(content), "Hello world!"))
if msg.BodyPos != expectedBodyPos {
t.Errorf("BodyPos: got %d, want %d", msg.BodyPos, expectedBodyPos)
}
}
func TestMessage_RewriteSubject(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
os.MkdirAll(subDir, 0755)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
// Подготовка мока сообщения
rawRecv := "Received: from mail.domain.name ([1.2.3.4] verified)"
mock := &MessageMock{
Envelope: []string{"P <s@domain.name>", "R <r@domain.name>", "S SMTP [1.2.3.4]"},
Headers: []string{
"From: s@domain.name",
rawRecv, // Наш целевой Received для HELO и Seen
"Subject: Old",
},
Body: "Body Content",
}
qid := 20002
// Создаем тестовый файл .msg
fname := createTestFile(t, qid, mock.Render())
// 1. Инициализируем сообщение (должно найти внешний Received и сохранить его)
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatalf("NewMessage failed: %v", err)
}
defer msg.Close()
// 2. Генерируем Seen-заголовок через метод структуры (логика rspamc)
seenHeader := msg.MakeSeen()
if len(seenHeader) == 0 {
t.Fatalf("MakeSeen returned empty string, check if NewMessage saved rawReceived")
}
newSubj := "SPAM: Original"
// Собираем слайс дополнительных заголовков
rspamdHdrs := []string{
"X-Spam-Score: 10.0",
seenHeader, // Добавляем сгенерированный Seen
}
// 3. Выполняем рерайт в директорию Submitted
err = msg.RewriteSubject(rspamdHdrs, newSubj)
if err != nil {
t.Fatalf("RewriteSubject failed: %v", err)
}
// 4. Проверяем результат в .sub файле
resPath := filepath.Join(subDir, "20002rs.sub")
res, err := os.ReadFile(resPath)
if err != nil {
t.Fatalf("Result file %s not found", resPath)
}
sRes := string(res)
// А. Проверка хеша
// Важно: hashReceived должен работать идентично внутри MakeSeen и здесь в тесте
expectedHash := hashReceived([]byte(rawRecv + "\n"))
if !strings.Contains(sRes, "X-Rspamd-Seen: "+expectedHash) {
t.Errorf("Seen header missing or hash mismatch.\nExpected hash: %s\nFull content:\n%s", expectedHash, sRes)
}
// Б. Проверка замены темы
expectedSubjLine := "Subject: " + newSubj
if !strings.Contains(sRes, expectedSubjLine) {
t.Errorf("New subject %q not found in file", expectedSubjLine)
}
// В. Проверка удаления старой темы
if strings.Contains(sRes, "Subject: Old") {
t.Error("Old subject 'Subject: Old' still present in the file!")
}
// Г. Проверка наличия заголовков из слайса
if !strings.Contains(sRes, "X-Spam-Score: 10.0") {
t.Error("Rspamd headers (X-Spam-Score) missing in rewritten file")
}
}
func TestNewMessage_Malformed(t *testing.T) {
t.Run("Unexpected EOF", func(t *testing.T) {
// Обрываем файл прямо в середине конверта
content := []byte("P <sender@domain.name>\nS SMTP [1.2.3.4]")
fname := createTestFile(t, 30003, content)
_, err := NewMessage(1, fname)
if err == nil || !strings.Contains(err.Error(), "unexpected end of envelope") {
t.Errorf("Expected EOF error, got: %v", err)
}
})
}
+110
View File
@@ -0,0 +1,110 @@
package cgp
import (
"bufio"
"io"
"os"
"strconv"
"time"
)
func NotifyTo(seq int, m *Message, to []string, header string, notifyfrom string, desc string) {
mailid := strconv.Itoa(m.QID)
sSeq := strconv.Itoa(seq)
filename := submitDir + "/" + mailid + "no.sub"
filetemp := submitDir + "/" + mailid + "no.tmp"
// Локальная функция-обертка для изоляции ресурсов (File, Writer)
err := func() error {
fh, err := os.Create(filetemp)
if err != nil {
return err
}
defer fh.Close()
w := bufio.NewWriterSize(fh, 8192)
defer w.Flush()
date := time.Now().Format(time.RFC1123Z)
boundary := "nextPart" + mailid + "." + sSeq
// 1. Конверт и заголовки MIME
w.WriteString("Return-Path: <>\n")
for _, rcpt := range to {
w.WriteString("Envelope-To: <")
w.WriteString(rcpt)
w.WriteString(">\n")
}
w.WriteString("From: ")
w.WriteString(notifyfrom)
w.WriteString("\nSubject: Notify Case ")
w.WriteString(mailid)
w.WriteString("\nDate: ")
w.WriteString(date)
w.WriteByte('\n')
w.WriteString(header) // X-Rspamd-Case
w.WriteString("\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"")
w.WriteString(boundary)
w.WriteString("\"\n\nThis is a multi-part message in MIME format.\n\n--")
w.WriteString(boundary)
// 2. Текстовое описание кейса
w.WriteString("\nContent-Type: text/plain; charset=UTF-8; format=flowed\nContent-Transfer-Encoding: 8bit\n\nmail id: ")
w.WriteString(mailid)
w.WriteString("\nmail from: ")
if len(m.From) > 0 {
w.WriteString(m.From)
} else {
w.WriteString("<>")
}
if len(m.Rcpts) > 0 {
w.WriteString("\nrcpt to: ")
w.WriteString(m.Rcpts[0])
for _, rcpt := range m.Rcpts[1:] {
w.WriteString("\n ")
w.WriteString(rcpt)
}
}
w.WriteString("\ndate: ")
w.WriteString(date)
w.WriteString("\n\ncase conditions:\n----------------\n")
w.WriteString(desc)
w.WriteString("\n\n\n--")
w.WriteString(boundary)
// 3. Аттачмент (Оригинальные RFC заголовки)
w.WriteString("\nContent-Disposition: attachment; filename=\"headers\"\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n")
hdrSize := m.BodyPos - m.HdrPos
if hdrSize > 0 {
sr := io.NewSectionReader(m.File, m.HdrPos, hdrSize)
if _, err = io.Copy(w, sr); err != nil {
return err
}
}
w.WriteString("\n--")
w.WriteString(boundary)
w.WriteString("--\n")
// Явный Flush и Close для проверки ошибок записи перед завершением функции
if err = w.Flush(); err != nil {
return err
}
return fh.Close()
}()
// Финализация: если была ошибка — чистим временный файл, если нет — переименовываем
if err != nil {
os.Remove(filetemp)
Putline("* ", sSeq, " [", mailid, "]: notify: ", err)
return
}
if err = os.Rename(filetemp, filename); err != nil {
os.Remove(filetemp)
Putline("* ", sSeq, " [", mailid, "]: notify rename error: ", err)
}
}
+157
View File
@@ -0,0 +1,157 @@
package cgp
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNotifyTo(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
os.MkdirAll(subDir, 0755)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
// 1. Готовим "донорское" письмо, из которого будем брать заголовки
qid := 999
mock := &MessageMock{
Envelope: []string{"P <s@domain.name>", "R <r@domain.name>", "S SMTP [1.2.3.4]"},
Headers: []string{"From: sender@domain.name", "Subject: Original", "X-Custom: Value"},
Body: "This body should NOT be in notification",
}
fname := createTestFile(t, qid, mock.Render())
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatal(err)
}
defer msg.Close()
// 2. Вызываем NotifyTo
to := []string{"admin@domain.name"}
notifyFrom := "postmaster@domain.name"
desc := "Spam policy violation detected"
header := "X-Rspamd-Case: 12345"
NotifyTo(1, msg, to, header, notifyFrom, desc)
// 3. Проверяем результат
resPath := filepath.Join(subDir, "999no.sub")
res, err := os.ReadFile(resPath)
if err != nil {
t.Fatalf("Notification file not created: %v", err)
}
sRes := string(res)
// ПРОВЕРКИ:
// А. Конверт (NotifyTo пишет Return-Path: <>)
if !strings.HasPrefix(sRes, "Return-Path: <>\nEnvelope-To: <admin@domain.name>") {
t.Error("Invalid envelope in notification")
}
// Б. MIME Boundary
boundaryLine := "boundary=\"nextPart999.1\""
if !strings.Contains(sRes, boundaryLine) {
t.Errorf("Boundary definition not found. Expected: %s", boundaryLine)
}
// В. Текстовое описание
if !strings.Contains(sRes, desc) || !strings.Contains(sRes, "mail id: 999") {
t.Error("Description or Mail ID missing in notification body")
}
// Г. Аттачмент (Заголовки)
// Проверяем, что заголовки из оригинала попали в аттачмент
if !strings.Contains(sRes, "attachment; filename=\"headers\"") {
t.Error("Attachment header missing")
}
if !strings.Contains(sRes, "X-Custom: Value") {
t.Error("Original headers missing in attachment")
}
// Д. Отсутствие тела оригинала (Проверка SectionReader)
if strings.Contains(sRes, "This body should NOT be in notification") {
t.Error("Original body leaked into notification headers attachment")
}
// Е. Закрывающий boundary
finalBoundary := "--nextPart999.1--"
if !strings.Contains(sRes, finalBoundary) {
t.Error("Final MIME boundary missing")
}
}
func TestNotifyTo_MultipleRcpts(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
os.MkdirAll(subDir, 0755)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
// Создаем сообщение с двумя получателями в конверте
mock := &MessageMock{
Envelope: []string{
"P <s@domain.name>",
"R <r1@domain.name>",
"R <r2@domain.name>",
"S SMTP [1.1.1.1]",
},
Headers: []string{"Subject: Test"},
Body: "Body",
}
qid := 123
fname := createTestFile(t, qid, mock.Render())
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatalf("Failed to create message: %v", err)
}
defer msg.Close()
// Получатели уведомления
to := []string{"admin1@domain.name", "admin2@domain.name"}
// Вызываем генерацию уведомления
NotifyTo(1, msg, to, "X-Rspamd-Scan: 1", "postmaster@domain.name", "Policy violation")
// Читаем результат
resPath := filepath.Join(subDir, "123no.sub")
res, err := os.ReadFile(resPath)
if err != nil {
t.Fatalf("Notification file not found: %v", err)
}
sRes := string(res)
// 1. Проверка Envelope-To (оба адреса должны быть в заголовках конверта)
if !strings.Contains(sRes, "Envelope-To: <admin1@domain.name>") ||
!strings.Contains(sRes, "Envelope-To: <admin2@domain.name>") {
t.Error("Not all notification recipients found in Envelope-To")
}
// 2. Проверка форматирования списка получателей в текстовой части.
// Судя по вашему дампу:
// "rcpt to: r1@domain.name" -> 3 пробела после двоеточия
// "\n r2@domain.name" -> 11 пробелов в начале строки
expectedFirst := "rcpt to: r1@domain.name"
expectedSecond := "\n r2@domain.name"
if !strings.Contains(sRes, expectedFirst) {
t.Errorf("First recipient line mismatch.\nWant: %q\nGot around: %q", expectedFirst, sRes[strings.Index(sRes, "rcpt to:"):strings.Index(sRes, "rcpt to:")+30])
}
if !strings.Contains(sRes, expectedSecond) {
t.Errorf("Indentation for second recipient mismatch.\nWant: %q", expectedSecond)
}
// 3. Проверка mail from (также со скобками)
if !strings.Contains(sRes, "mail from: s@domain.name") {
t.Error("Mail from line mismatch")
}
}
+136 -34
View File
@@ -2,45 +2,147 @@ package config
import (
"flag"
"strings"
"fmt"
"os"
"path/filepath"
"time"
"github.com/jinzhu/configor"
"git.vsu.ru/ai/rspamd-cgp/cgp"
)
// Эти значения будут перезаписаны при сборке через -ldflags
var (
Version = "dev"
Commit = "none"
BuildTime = "unknown"
)
type Operation struct {
Description string
Direction Direction
Discard bool
MirrorTo []string
NotifyRcpts bool
NotifyTo []string
}
type Config struct {
AuthservId string
Debug bool
Host string
MirrorDiscard bool
MirrorTo []string
Timeout time.Duration
AuthservId string
Host string `default:"localhost:11333"`
Timeout time.Duration `default:"15s"`
NotifyFrom string `required:"true"`
Actions map[string]*Operation
Symbols map[string]*Operation
debug bool
outbound bool
showVersion bool
}
func New() *Config {
var configInstance *Config
config := new(Config)
var mirrorTo string
flag.StringVar(&config.AuthservId, "authserv-id", "", "Authentication Identifier (default CommuniGate Pro Main Domain)")
flag.StringVar(&config.Host, "host", "localhost:11333", "Rspamd host to connect")
flag.BoolVar(&config.MirrorDiscard, "mirror-discard", false, "Mirror then discard selected messages")
flag.StringVar(&mirrorTo, "mirror-to", "", "Mirror selected messages to email")
flag.DurationVar(&config.Timeout, "timeout", 15*time.Second, "Rspamd request timeout")
flag.BoolVar(&config.Debug, "debug", false, "Export debug information (for developers)")
flag.Parse()
if len(mirrorTo) > 0 {
config.MirrorTo = strings.Split(strings.ReplaceAll(mirrorTo, " ", ""), ",")
}
if config.MirrorDiscard && len(config.MirrorTo) == 0 {
config.MirrorDiscard = false
}
if config.Timeout < time.Second {
config.Timeout *= time.Second
}
return config
func Action(a string) (op *Operation, ok bool) {
op, ok = configInstance.Actions[a]
return
}
func AuthservId() string {
return configInstance.AuthservId
}
func Debug() bool {
return configInstance.debug
}
func GetVersion() string {
return fmt.Sprintf("rspamd-cgp\n version: %s\n commit: %s\n built: %s\n",
Version, Commit, BuildTime)
}
func Host() string {
return configInstance.Host
}
func New(args []string) (*Config, error) {
c := &Config{}
fs := flag.NewFlagSet("rspamd-cgp", flag.ContinueOnError)
dir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
var configfile string
var configdump bool
var configtest bool
var err error
fs.StringVar(&configfile, "config", dir+"/rspamd-cgp.yml", "Set configuration file")
fs.BoolVar(&configdump, "configdump", false, "Perform configuration file dump")
fs.BoolVar(&configtest, "configtest", false, "Perform configuration file test")
fs.BoolVar(&c.debug, "debug", false, "Run in debug mode")
fs.BoolVar(&c.outbound, "outbound", false, "Outbound message flow processing")
fs.BoolVar(&c.showVersion, "version", false, "print version and exit")
if err = fs.Parse(args); err != nil {
return nil, err
}
err = configor.Load(c, configfile)
if err != nil {
return nil, err
}
if configdump {
dumpConfig(c)
if err = validateConfig(c); err != nil {
fmt.Println("config:", err)
os.Exit(1)
}
os.Exit(0)
}
if configtest {
if err = validateConfig(c); err != nil {
fmt.Println("config:", err)
os.Exit(1)
} else {
fmt.Println("config: Syntax OK")
os.Exit(0)
}
}
if c.AuthservId == "" {
if c.AuthservId, err = cgp.MainDomain(); err != nil {
return nil, fmt.Errorf("can not detect Main Domain: %v", err)
}
}
c.Host = "http://" + c.Host + "/checkv2"
return c, nil
}
func NotifyFrom() string {
return configInstance.NotifyFrom
}
func Outbound() bool {
return configInstance.outbound
}
func SetGlobal(c *Config) {
configInstance = c
}
func ShowVersion() bool {
return configInstance.showVersion
}
func Symbols() map[string]*Operation {
return configInstance.Symbols
}
func Timeout() time.Duration {
return configInstance.Timeout
}
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestConfig_New(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.yml")
// Готовим тестовый конфиг.
// YAML теперь парсится через UnmarshalYAML в наш тип Direction.
yamlContent := `
authservid: "test.domain.name"
notifyfrom: "postmaster@test.domain.name"
host: "127.0.0.1:11333"
actions:
reject:
description: "Spam rejected"
direction: "in"
notifyto: ["admin@domain.name"]
`
if err := os.WriteFile(configFile, []byte(yamlContent), 0644); err != nil {
t.Fatal(err)
}
t.Run("Full initialization", func(t *testing.T) {
args := []string{"-config", configFile, "-debug"}
cfg, err := New(args)
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if cfg.Timeout != 15*time.Second {
t.Errorf("Expected default timeout 15s, got %v", cfg.Timeout)
}
if cfg.AuthservId != "test.domain.name" {
t.Errorf("AuthservId mismatch: %s", cfg.AuthservId)
}
if !cfg.debug {
t.Error("Debug flag was not set")
}
expectedHost := "http://127.0.0.1:11333/checkv2"
if cfg.Host != expectedHost {
t.Errorf("Host transformation failed. Got %s, want %s", cfg.Host, expectedHost)
}
})
t.Run("Direction enum parsing", func(t *testing.T) {
args := []string{"-config", configFile}
cfg, _ := New(args)
op, ok := cfg.Actions["reject"]
// Проверяем соответствие константе DirIn (1)
if !ok || op.Direction != DirIn {
t.Errorf("Action 'reject' should have direction DirIn (1), got %v", op.Direction)
}
// Проверка строкового представления
if op.Direction.String() != "in" {
t.Errorf("String representation failed. Got %s, want 'in'", op.Direction.String())
}
})
t.Run("Invalid direction in YAML", func(t *testing.T) {
badHdr := "notifyfrom: \"a@b.c\"\nactions:\n bad:\n direction: \"upwards\""
badFile := filepath.Join(tmpDir, "bad_config.yml")
os.WriteFile(badFile, []byte(badHdr), 0644)
_, err := New([]string{"-config", badFile})
if err == nil {
t.Error("Expected error for invalid direction 'upwards', but got nil")
} else if !strings.Contains(err.Error(), "invalid direction") {
t.Errorf("Unexpected error message: %v", err)
}
})
}
func TestValidateConfig(t *testing.T) {
tests := []struct {
name string
cfg *Config
wantErr bool
}{
{
name: "Valid config",
cfg: &Config{
NotifyFrom: "scanner@domain.name",
Actions: map[string]*Operation{
"quarantine": {Direction: DirBoth},
},
},
wantErr: false,
},
{
name: "Invalid email",
cfg: &Config{
NotifyFrom: "not-an-email",
},
wantErr: true,
},
{
name: "Invalid direction enum",
cfg: &Config{
NotifyFrom: "ok@domain.name",
Actions: map[string]*Operation{
"test": {Direction: Direction(99)}, // Несуществующее значение
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("validateConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestVersionOutput(t *testing.T) {
Version = "1.0.0"
Commit = "abcde"
out := GetVersion()
if !contains(out, "1.0.0") || !contains(out, "abcde") {
t.Errorf("Version output mismatch: %s", out)
}
}
func TestConfig_AuthservIdFallback(t *testing.T) {
tmpDir := t.TempDir()
settingsDir := filepath.Join(tmpDir, "Settings")
os.MkdirAll(settingsDir, 0755)
mainSettingsContent := []byte(`DomainName = "domain.name";`)
os.WriteFile(filepath.Join(settingsDir, "Main.settings"), mainSettingsContent, 0644)
configFile := filepath.Join(tmpDir, "config.yml")
yamlContent := []byte("notifyfrom: \"postmaster@domain.name\"\nhost: \"localhost:11333\"")
os.WriteFile(configFile, yamlContent, 0644)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
t.Run("Detect AuthservId from Main.settings", func(t *testing.T) {
args := []string{"-config", configFile}
cfg, err := New(args)
if err != nil {
t.Fatalf("New() failed to detect domain: %v", err)
}
if cfg.AuthservId != "domain.name" {
t.Errorf("Expected AuthservId 'domain.name', got %q", cfg.AuthservId)
}
})
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
+51
View File
@@ -0,0 +1,51 @@
package config
import (
"fmt"
)
type Direction int
const (
DirBoth Direction = iota
DirIn
DirOut
)
// MarshalYAML позволяет Marshal печатать строку вместо числа
func (d Direction) MarshalYAML() (any, error) {
return d.String(), nil
}
// String реализует интерфейс fmt.Stringer
func (d Direction) String() string {
switch d {
case DirIn:
return "in"
case DirOut:
return "out"
case DirBoth:
return "both"
default:
return fmt.Sprintf("unknown(%d)", d)
}
}
// UnmarshalYAML позволяет прозрачно читать "in", "out", "both" из конфига
func (d *Direction) UnmarshalYAML(unmarshal func(any) error) error {
var s string
if err := unmarshal(&s); err != nil {
return err
}
switch s {
case "in":
*d = DirIn
case "out":
*d = DirOut
case "both", "": // Пустое значение трактуем как both
*d = DirBoth
default:
return fmt.Errorf("invalid direction: %s (want: in, out, both)", s)
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"fmt"
"regexp"
"gopkg.in/yaml.v3"
)
var reMail *regexp.Regexp
func dumpConfig(c *Config) {
yml, err := yaml.Marshal(c)
if err != nil {
fmt.Println("config:", err)
} else {
fmt.Print(string(yml))
fmt.Println("debug:", c.debug)
fmt.Println("outbound:", c.outbound)
fmt.Println("showVersion:", c.showVersion)
}
}
func validateConfig(c *Config) (err error) {
reMail = regexp.MustCompile(`^\S+?@\S+$`)
if err = validateConfigOp(c.NotifyFrom); err != nil {
return
}
if err = validateConfigEntry(c.Actions); err != nil {
return
}
if err = validateConfigEntry(c.Symbols); err != nil {
return
}
return
}
func validateConfigEntry(entry map[string]*Operation) (err error) {
for e, op := range entry {
// Проверка диапазона Direction
if op.Direction < DirBoth || op.Direction > DirOut {
return fmt.Errorf("%s: invalid direction index: %d", e, op.Direction)
}
if err = validateConfigOps(op.NotifyTo); err != nil {
err = fmt.Errorf("%s: NotifyTo: %v", e, err)
break
}
if err = validateConfigOps(op.MirrorTo); err != nil {
err = fmt.Errorf("%s: MirrorTo: %v", e, err)
break
}
}
return
}
func validateConfigOp(m string) (err error) {
if !reMail.MatchString(m) {
err = fmt.Errorf("invalid mail: %s", m)
}
return
}
func validateConfigOps(mail []string) (err error) {
for _, m := range mail {
if err = validateConfigOp(m); err != nil {
break
}
}
return
}
+1
View File
@@ -0,0 +1 @@
domain.ru
+1
View File
@@ -0,0 +1 @@
/domain\.ru/i
+41
View File
@@ -0,0 +1,41 @@
################################################################################
# Пример multimap.conf для Rspamd
################################################################################
FAKE_LOCAL_FROM {
require_symbols = "!AUTHENTICATED_USER & !TRUSTED_HOST & !CGP_RPOLL";
type = "header";
header = "from";
filter = "email:domain";
map = "$LOCAL_CONFDIR/local.d/maps.d/local_domains.map";
description = "Fake local mail in From: header";
}
FAKE_LOCAL_FROM_NAME {
require_symbols = "!AUTHENTICATED_USER & !TRUSTED_HOST & !CGP_RPOLL";
type = "header";
header = "from";
filter = "email:name";
map = "$LOCAL_CONFDIR/local.d/maps.d/local_domains_re.map";
regexp = true;
description = "Fake local mail in Realname of From: header";
}
################################################################################
# Используется rspamd-cgp для исключения внешних адресов из рассылки оповещений.
# Применяется, если в конфигурационном файле rspamd-cgp.yml указан
# notifyrcpts: true
#
RCPTS_DOMAINS_LOCAL {
type = "rcpt";
extract_from = "smtp";
filter = "email:domain";
map = "$LOCAL_CONFDIR/local.d/maps.d/local_domains.map";
description = "Recipients domains are local";
}
TRUSTED_HOST {
type = "ip";
map = "$LOCAL_CONFDIR/local.d/maps.d/trusted_hosts.map";
description = "Trusted hosts";
}
+6
View File
@@ -0,0 +1,6 @@
local reconf = config['regexp']
reconf['CGP_RPOLL'] = {
description = 'CommuniGate Pro RPOLL Received header',
re = 'Received=/with RPOLL /{raw_header}'
}
+17
View File
@@ -0,0 +1,17 @@
################################################################################
# Пример settings.conf для Rspamd.
################################################################################
################################################################################
# Устанавливает символ AUTHENTICATED_USER для аутентифицированных сообщений.
#
authenticated {
priority = high;
authenticated = yes;
apply {
AUTHENTICATED_USER = 0.0;
}
symbols [
"AUTHENTICATED_USER"
]
}
+1
View File
@@ -0,0 +1 @@
1.2.3.4
+11 -5
View File
@@ -1,10 +1,16 @@
module git.vsu.ru/ai/rspamd-cgp
go 1.23
require github.com/json-iterator/go v1.1.12
go 1.25
require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0
github.com/jinzhu/configor v1.2.2
github.com/maypok86/otter v1.2.4
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/gammazero/deque v1.2.1 // indirect
)
+19 -12
View File
@@ -1,16 +1,23 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+40 -18
View File
@@ -2,52 +2,74 @@ package main
import (
"bufio"
"fmt"
"bytes"
"io"
"os"
"strconv"
"git.vsu.ru/ai/rspamd-cgp/cgp"
"git.vsu.ru/ai/rspamd-cgp/config"
"git.vsu.ru/ai/rspamd-cgp/rspamc"
"git.vsu.ru/ai/rspamd-cgp/utils"
)
func main() {
var arg, cmd string
var seq int
var line []byte
var err error
var n int
if err := cgp.InitStdoutFd(); err != nil {
os.Stderr.WriteString("failed to init CGP stdout\n")
os.Exit(1)
}
conf, err := config.New(os.Args[1:])
if err != nil {
os.Stderr.WriteString("config: " + err.Error() + "\n")
os.Exit(1)
}
config.SetGlobal(conf)
if config.ShowVersion() {
os.Stderr.WriteString(config.GetVersion())
return
}
in := bufio.NewReader(os.Stdin)
finalize:
loop:
for {
line, err = in.ReadSlice('\n')
line, err := in.ReadSlice('\n')
if err != nil {
cgp.Putline("* error: %s\n", err)
break finalize
if err != io.EOF {
cgp.Putline("* stdin error: ", err)
}
break loop
}
n, err = fmt.Sscan(string(line), &seq, &cmd, &arg)
if err != nil && err != io.EOF {
cgp.Putline("* error: %s\n", err)
parts := bytes.Fields(line)
n := len(parts)
if n < 2 {
continue
}
seq, err := strconv.Atoi(utils.Bytes2string(parts[0]))
if err != nil {
continue
}
cmd := utils.Bytes2string(parts[1])
switch {
case cmd == "FILE" && n == 3:
go rspamc.Scan(seq, arg)
go rspamc.Scan(seq, string(parts[2]))
case cmd == "INTF" && n == 3:
cgp.Intf(seq, arg)
cgp.Intf(seq, utils.Bytes2string(parts[2]))
case cmd == "QUIT" && n == 2:
cgp.Ok(seq)
break finalize
break loop
default:
cgp.Putline("* bad command: %s\n", line)
cgp.Putline("* bad command: ", line)
}
}
}
+221
View File
@@ -0,0 +1,221 @@
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"git.vsu.ru/ai/rspamd-cgp/config"
"git.vsu.ru/ai/rspamd-cgp/rspamc"
)
func TestIntegration_FullMatrix(t *testing.T) {
// 1. Подготовка бинарника
_, filename, _, _ := runtime.Caller(0)
testSrcDir := filepath.Dir(filename)
helperPath, _ := filepath.Abs(filepath.Join(testSrcDir, "rspamd-cgp"))
if _, err := os.Stat(helperPath); os.IsNotExist(err) {
t.Fatalf("Helper binary not found. Build it: go build -o rspamd-cgp")
}
type testCase struct {
name string
isOutbound bool
rspamdResp string
configYaml string
expect []string // Ожидаемые подстроки в ответе хелпера
checkFile bool // Нужно ли проверять наличие .sub файла
}
matrix := []testCase{
{
name: "1. IN: Clean Message",
rspamdResp: `{"action":"no action","score":0.1,"symbols":{"ALL_OK":{"score":0}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\n",
expect: []string{"2 OK"},
},
{
name: "2. IN: Virus -> Mirror + Discard",
rspamdResp: `{"action":"no action","symbols":{"VIRUS":{"score":10}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\nsymbols:\n VIRUS: { discard: true, mirrorto: [\"quarantine@domain.name\"] }\n",
expect: []string{"MIRRORTO", "quarantine@domain.name", "DISCARD"},
},
{
name: "3. IN: Spam -> Rewrite Subject",
rspamdResp: `{"action":"rewrite subject","subject":"[SPAM] Test","score":8,"symbols":{"SPAM_LOW":{"score":5}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\nsymbols:\n SPAM_LOW: { Discard: true }\n",
expect: []string{"2 ADDHEADER", "DISCARD"},
checkFile: true,
},
{
name: "4. IN: High Score -> Reject Action (Junk-Score)",
rspamdResp: `{"action":"reject","score":15}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\n",
expect: []string{"ADDHEADER", "X-Junk-Score: [XXXXXXXXXX]", "OK"},
},
{
name: "5. OUT: Virus -> Discard (No Mirror for outbound)",
isOutbound: true,
rspamdResp: `{"action":"no action","symbols":{"VIRUS":{"score":10}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\nsymbols:\n VIRUS: { discard: true, mirrorto: [\"quarantine@domain.name\"] }\n",
expect: []string{"DISCARD"},
},
}
for _, tc := range matrix {
t.Run(tc.name, func(t *testing.T) {
// Создаем песочницу
tmpDir := t.TempDir()
os.MkdirAll(filepath.Join(tmpDir, "Queue"), 0777)
os.MkdirAll(filepath.Join(tmpDir, "Submitted"), 0777)
// Поднимаем эмулятор Rspamd
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, tc.rspamdResp)
}))
defer ts.Close()
// Пишем конфиг
confPath := filepath.Join(tmpDir, "config.yml")
os.WriteFile(confPath, []byte(fmt.Sprintf(tc.configYaml, strings.TrimPrefix(ts.URL, "http://"))), 0644)
// Создаем письмо
qid := "999"
msgPath := filepath.Join(tmpDir, "Queue", qid+".msg")
msgData := "S <s@v.ru> SMTP [1.1.1.1]\nP <s@v.ru>\nR <r@v.ru>\n\n" +
"Received: from x by domain.name; Thu, 05 Mar 2026 12:00:00 +0300\n" +
"From: s@v.ru\nSubject: Test\n\nBody content"
os.WriteFile(msgPath, []byte(msgData), 0644)
// Запуск хелпера
args := []string{"-config", confPath}
if tc.isOutbound {
args = append(args, "-outbound")
}
cmd := exec.Command(helperPath, args...)
cmd.Dir = tmpDir
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
stderrReader, _ := cmd.StderrPipe()
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
// Читаем stderr, чтобы не блокировать процесс
go io.Copy(io.Discard, stderrReader)
// Читаем результат
resChan := make(chan string)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if len(line) > 0 && line[0] >= '0' && line[0] <= '9' && !strings.Contains(line, "INTF") {
resChan <- line
return
}
}
}()
// Команды протокола
fmt.Fprint(stdin, "1 INTF 4\n")
fmt.Fprintf(stdin, "2 FILE Queue/%s.msg\n", qid)
select {
case res := <-resChan:
for _, exp := range tc.expect {
if !strings.Contains(res, exp) {
t.Errorf("[%s] Expected '%s', got: %s", tc.name, exp, res)
}
}
case <-time.After(3 * time.Second):
cmd.Process.Kill()
t.Fatalf("[%s] Timeout waiting for response", tc.name)
}
// Проверка файла в Submitted
if tc.checkFile {
subPath := filepath.Join(tmpDir, "Submitted", qid+"rs.sub")
if _, err := os.Stat(subPath); os.IsNotExist(err) {
t.Errorf("[%s] File %s not created!", tc.name, subPath)
}
}
fmt.Fprint(stdin, "3 QUIT\n")
stdin.Close()
cmd.Wait()
})
}
}
func createCgpCompliantFile(t *testing.T, qid int) string {
path := filepath.Join(os.TempDir(), fmt.Sprintf("%d.msg", qid))
var buf bytes.Buffer
// 1. Формат CGP Envelope
buf.WriteString("S <test@domain.name> SMTP [2001:67c:418:2020::21]\n")
buf.WriteString(fmt.Sprintf("P <%d@domain.name>\n", qid))
buf.WriteString("R <rcpt@domain.name>\n")
buf.WriteString("\n") // Конец Envelope
// 2. Тело письма (RFC5322)
buf.WriteString("Received: from mail.domain.name ([1.2.3.4] verified)\n")
buf.WriteString("From: <sender@domain.name>\n")
buf.WriteString("Subject: Bench\n")
buf.WriteString("\n")
buf.WriteString("Body content goes here")
os.WriteFile(path, buf.Bytes(), 0644)
return path
}
func BenchmarkRspamc_Scan_RealWork(b *testing.B) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"action": "no action", "score": -2.75, "symbols": {"RCPTS_DOMAINS_LOCAL": {"options": ["domain.name"]}}}`)
}))
defer ts.Close()
// Создаем правильный файл
tmpMsg := createCgpCompliantFile(nil, 555)
defer os.Remove(tmpMsg)
// Глушим syscall.Write
null, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
oldStdout, _ := syscall.Dup(1)
syscall.Dup2(int(null.Fd()), 1)
oldArgs := os.Args
os.Args = []string{"rspamd-cgp"}
defer func() { os.Args = oldArgs }()
conf, err := config.New(os.Args[1:])
if err != nil {
os.Stderr.WriteString("config: " + err.Error() + "\n")
os.Exit(1)
}
config.SetGlobal(conf)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
rspamc.Scan(i, tmpMsg)
}
syscall.Dup2(oldStdout, 1)
}
+334
View File
@@ -0,0 +1,334 @@
package rspamc
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"git.vsu.ru/ai/rspamd-cgp/cgp"
"git.vsu.ru/ai/rspamd-cgp/config"
"git.vsu.ru/ai/rspamd-cgp/utils"
)
const (
headerCase string = "X-Rspamd-Case: "
headerJunkG string = "X-Junk-Score: [XX]"
headerJunkA string = "X-Junk-Score: [XXXX]"
headerJunkR string = "X-Junk-Score: [XXXXXXXXXX]"
)
func filterLocalRcpts(rcpts []string, res *RspamdResponse) []string {
filtered := make([]string, 0, len(rcpts))
if res.Symbols == nil {
return filtered
}
v, ok := res.Symbols["RCPTS_DOMAINS_LOCAL"]
if !ok || v == nil || len(v.Options) == 0 {
return filtered
}
for _, rcpt := range rcpts {
at := strings.IndexByte(rcpt, '@')
if at < 0 {
continue
}
rcptDomain := rcpt[at+1 : len(rcpt)-1]
for _, domain := range v.Options {
if rcptDomain == domain {
filtered = append(filtered, rcpt)
break
}
}
}
return filtered
}
func isOpAccept(dir config.Direction, outbound bool) bool {
switch dir {
case config.DirBoth:
return true
case config.DirOut:
return outbound
case config.DirIn:
return !outbound
default:
return false
}
}
func makeHeaders(res *RspamdResponse) (headers []string) {
if res.DKIMSignature != "" {
headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
}
if res.Milter != nil && res.Milter.AddHeaders != nil {
for h, vh := range res.Milter.AddHeaders {
if vh.Value != "" {
headers = append(headers, h+": "+vh.Value)
}
}
}
return
}
func makeHeadersOutbound(res *RspamdResponse) (headers []string) {
if res.DKIMSignature != "" {
headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
}
return
}
func makeOpSum(res *RspamdResponse, action string) (*config.Operation, string, string, string) {
var casea []string
var desca []string
opsum := new(config.Operation)
// Обработка Action
if op, ok := config.Action(action); ok {
if isOpAccept(op.Direction, config.Outbound()) {
opsum.Discard = op.Discard
opsum.MirrorTo = op.MirrorTo
opsum.NotifyRcpts = op.NotifyRcpts
opsum.NotifyTo = op.NotifyTo
casea = append(casea, action)
if len(op.Description) > 0 {
desca = append(desca, action+": "+op.Description)
} else {
desca = append(desca, action)
}
if config.Debug() {
printSelectedOp("Action", action, op.Direction, config.Outbound())
}
}
}
// Обработка Symbols
if res.Symbols != nil {
for symbol, op := range config.Symbols() {
if isOpAccept(op.Direction, config.Outbound()) {
if v, ok := res.Symbols[symbol]; ok && v != nil {
opsum.Discard = opsum.Discard || op.Discard
opsum.MirrorTo = append(opsum.MirrorTo, op.MirrorTo...)
opsum.NotifyRcpts = opsum.NotifyRcpts || op.NotifyRcpts
opsum.NotifyTo = append(opsum.NotifyTo, op.NotifyTo...)
casea = append(casea, symbol)
if len(op.Description) > 0 {
desca = append(desca, symbol+": "+op.Description)
} else if v.Description != "" {
desca = append(desca, symbol+": "+v.Description)
} else {
desca = append(desca, symbol)
}
if config.Debug() {
printSelectedOp("Symbol", symbol, op.Direction, config.Outbound())
}
}
}
}
}
if len(casea) > 0 {
var sb strings.Builder
sb.Grow(256)
// Формирования casework
sb.WriteString("discard ")
sb.WriteString(strconv.FormatBool(opsum.Discard))
sb.WriteString("; notifyrcpts ")
sb.WriteString(strconv.FormatBool(opsum.NotifyRcpts))
if len(opsum.MirrorTo) > 0 {
opsum.MirrorTo = utils.UniqueSliceElementsNonEmpty(opsum.MirrorTo)
sb.WriteString("; mirrorto ")
sb.WriteString(strings.Join(opsum.MirrorTo, ","))
}
if len(opsum.NotifyTo) > 0 {
opsum.NotifyTo = utils.UniqueSliceElementsNonEmpty(opsum.NotifyTo)
sb.WriteString("; notifyto ")
sb.WriteString(strings.Join(opsum.NotifyTo, ","))
}
return opsum, strings.Join(casea, ","), sb.String(), strings.Join(desca, "\n")
}
return nil, "", "", ""
}
func printSelectedOp(optype, opname string, dir config.Direction, outbound bool) {
if outbound {
fmt.Fprintf(os.Stderr, "%s '%s' selected for outbound flow: direction %s\n", optype, opname, dir.String())
} else {
fmt.Fprintf(os.Stderr, "%s '%s' selected for inbound flow: direction %s\n", optype, opname, dir.String())
}
}
func printResponse(debugBuf *bytes.Buffer) {
var pretty bytes.Buffer
if err := json.Indent(&pretty, debugBuf.Bytes(), "", " "); err == nil {
os.Stderr.Write(pretty.Bytes())
os.Stderr.Write([]byte("\n"))
}
}
// procAction обрабатывает вердикт Rspamd согласно BNF:
// SEQ [ADDHEADER "h"] [MIRRORTO "a"] {OK|DISCARD}
func procAction(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse,
headers []string, hci, notifyfrom, desc string, t int) {
var actualMirror []string
var discard bool
// Уведомления
procNotifications(seq, msg, opsum, res, hci, notifyfrom, desc)
// Определение намерения
if opsum != nil {
discard = opsum.Discard
actualMirror = opsum.MirrorTo
} else {
discard = (t == 2)
}
// Валидация MirrorTo (Seen tag)
if len(actualMirror) > 0 {
if seenHdr := msg.MakeSeen(); seenHdr != "" {
headers = append(headers, seenHdr)
} else {
cgp.Putline("* ", seq, " [", msg.QID, "]: warning: MirrorTo skipped (no Seen tag)")
actualMirror = nil
}
}
// Оптимизация ADDHEADER
if discard && len(actualMirror) == 0 {
headers = nil
}
// Финальный ответ
cgp.MirrorTo(seq, msg, actualMirror, headers, discard)
}
// procActionRS обрабатывает Rewrite Subject через создание .sub файла в Submitted/
func procActionRS(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse,
headers []string, hci, notifyfrom, desc string) {
// Уведомления
procNotifications(seq, msg, opsum, res, hci, notifyfrom, desc)
seenHdr := msg.MakeSeen()
// FALLBACK: Метки нет — НИКАКИХ MIRRORTO.
// Мы только модифицируем текущее письмо (ADDHEADER) и отдаем OK.
if seenHdr == "" {
cgp.Putline("* ", seq, " [", msg.QID, "]: warning: RewriteSubject fallback (no Seen tag). MirrorTo suppressed.")
// Добавляем заголовок с предложенной темой к оригиналу
headers = append(headers, "X-Spam-Subject: "+res.Subject)
// Явный вызов метода, не имеющего отношения к дублированию писем
cgp.AddHeader(seq, headers)
return
}
// ШТАТНЫЙ РЕЖИМ: Метка есть, можем безопасно делать MIRRORTO
headers = append(headers, seenHdr)
// Пишем новый файл в Submitted/
if err := msg.RewriteSubject(headers, res.Subject); err != nil {
cgp.Failure(seq, msg.QID, fmt.Errorf("RewriteSubject failed: %v", err))
return
}
// Удаляем оригинал.
// Если в opsum был MirrorTo, он уйдет в этой же строке (SEQ [MIRRORTO] DISCARD).
var actualMirror []string
if opsum != nil {
actualMirror = opsum.MirrorTo
}
// Финальный ответ
cgp.MirrorTo(seq, msg, actualMirror, headers, true)
}
func procActionSwitch(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, headers []string, hci, action, desc string) {
outbound := config.Outbound()
notifyFrom := config.NotifyFrom()
switch action {
case "no action":
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 0)
case "discard":
if !outbound {
headers = append(headers, headerJunkR)
}
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 2)
case "quarantine", "reject":
if !outbound {
headers = append(headers, headerJunkR)
}
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
case "rewrite subject":
if res.Subject != "" {
procActionRS(seq, msg, opsum, res, headers, hci, notifyFrom, desc)
} else {
if !outbound {
headers = append(headers, headerJunkA)
}
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
}
case "add header":
if !outbound {
headers = append(headers, headerJunkA)
}
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
case "greylist", "soft reject":
if !outbound {
headers = append(headers, headerJunkG)
}
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
default:
cgp.Failure(seq, msg.QID, fmt.Errorf("Unknown action: %v", action))
}
}
func procNotifications(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, hci, notifyfrom, desc string) {
if opsum == nil || (!opsum.NotifyRcpts && len(opsum.NotifyTo) == 0) {
return
}
// Собираем всех кандидатов на получение уведомления
to := make([]string, 0, len(opsum.NotifyTo)+len(msg.Rcpts))
to = append(to, opsum.NotifyTo...)
if opsum.NotifyRcpts {
// Фильтруем только локальных или специфичных получателей на основе вердикта
rcptsFiltered := filterLocalRcpts(msg.Rcpts, res)
to = append(to, rcptsFiltered...)
}
// Убираем дубликаты и пустые строки перед отправкой
to = utils.UniqueSliceElementsNonEmpty(to)
if len(to) > 0 && notifyfrom != "" {
cgp.NotifyTo(seq, msg, to, hci, notifyfrom, desc)
}
}
+103
View File
@@ -0,0 +1,103 @@
package rspamc
import (
"testing"
"git.vsu.ru/ai/rspamd-cgp/config"
)
func TestMakeOpSum_Logic(t *testing.T) {
// Инициализируем конфиг для теста
conf := &config.Config{
Actions: map[string]*config.Operation{
"add header": {Direction: config.DirBoth, Discard: false, NotifyTo: []string{"admin@domain.name"}},
},
Symbols: map[string]*config.Operation{
"VIRUS": {Direction: config.DirBoth, Discard: true, MirrorTo: []string{"quarantine@domain.name"}},
"SPAM": {Direction: config.DirIn, Discard: false, MirrorTo: []string{"spam-box@domain.name"}},
},
}
config.SetGlobal(conf)
t.Run("Merge Action and Virus Symbol", func(t *testing.T) {
res := &RspamdResponse{
Action: "add header",
Symbols: map[string]*Symbol{
"VIRUS": {Score: 10},
},
}
// По умолчанию работаем как Inbound (config.outbound = false)
opsum, cases, _, _ := makeOpSum(res, "add header")
if opsum == nil {
t.Fatal("Expected opsum, got nil")
}
if !opsum.Discard {
t.Error("Discard must be TRUE because of VIRUS symbol")
}
if len(opsum.MirrorTo) != 1 || opsum.MirrorTo[0] != "quarantine@domain.name" {
t.Errorf("MirrorTo mismatch: %v", opsum.MirrorTo)
}
if cases != "add header,VIRUS" {
t.Errorf("Cases mismatch: %s", cases)
}
})
t.Run("Direction awareness (Manual Check)", func(t *testing.T) {
// Для этого теста проверим логику фильтрации напрямую через isOpAccept,
// так как подменить глобальное состояние outbound без рефлексии или
// пересоздания конфига через New() сложно.
tests := []struct {
name string
dir config.Direction
outbound bool
want bool
}{
{"Inbound: In accept", config.DirIn, false, true},
{"Inbound: Out reject", config.DirOut, false, false},
{"Inbound: Both accept", config.DirBoth, false, true},
{"Outbound: In reject", config.DirIn, true, false},
{"Outbound: Out accept", config.DirOut, true, true},
{"Outbound: Both accept", config.DirBoth, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isOpAccept(tt.dir, tt.outbound); got != tt.want {
t.Errorf("isOpAccept(%v, %v) = %v; want %v", tt.dir, tt.outbound, got, tt.want)
}
})
}
})
}
func TestFilterLocalRcpts_Scenarios(t *testing.T) {
res := &RspamdResponse{
Symbols: map[string]*Symbol{
"RCPTS_DOMAINS_LOCAL": {
Options: []string{"domain.name"},
},
},
}
tests := []struct {
name string
input []string
expected int
}{
{"All Local", []string{"<a@domain.name>", "<b@domain.name>"}, 2},
{"Mixed", []string{"<a@domain.name>", "<external@gmail.com>"}, 1},
{"None Local", []string{"<hacker@evil.com>"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := filterLocalRcpts(tt.input, res)
if len(out) != tt.expected {
t.Errorf("Got %d, want %d for %s", len(out), tt.expected, tt.name)
}
})
}
}
+107 -128
View File
@@ -2,177 +2,156 @@ package rspamc
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"os"
"reflect"
"strconv"
"strings"
json "github.com/json-iterator/go"
"sync"
"git.vsu.ru/ai/rspamd-cgp/cgp"
"git.vsu.ru/ai/rspamd-cgp/config"
)
const (
headerJunkG string = "X-Junk-Score: [XX]"
headerJunkA string = "X-Junk-Score: [XXXX]"
headerJunkR string = "X-Junk-Score: [XXXXXXXXXX]"
type Duration float64
func (d Duration) Append(dst []byte) []byte {
return strconv.AppendFloat(dst, float64(d), 'f', 6, 64)
}
type RspamdResponse struct {
Action string `json:"action"`
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
TimeReal Duration `json:"time_real"`
Subject string `json:"subject,omitempty"`
DKIMSignature string `json:"dkim-signature,omitempty"`
// Milter может отсутствовать (nil), если нет действий
Milter *struct {
AddHeaders map[string]struct {
Value string `json:"value"`
} `json:"add_headers"`
} `json:"milter,omitempty"`
// Symbols могут отсутствовать (nil)
Symbols map[string]*Symbol `json:"symbols,omitempty"`
}
type Symbol struct {
Score float64 `json:"score"`
Description string `json:"description,omitempty"`
Options []string `json:"options,omitempty"`
}
var (
client *http.Client
clientOne sync.Once
)
var authservId string
var client *http.Client
var mirrorDiscard bool
var mirrorTo []string
var host string
var debug bool
func init() {
config := config.New()
if config.AuthservId != "" {
authservId = config.AuthservId
} else {
authservId = cgp.MainDomain
}
mirrorDiscard = config.MirrorDiscard
mirrorTo = config.MirrorTo
host = "http://" + config.Host + "/checkv2"
debug = config.Debug
tr := &http.Transport{
DisableCompression: true,
}
client = &http.Client{
Timeout: config.Timeout,
Transport: tr,
}
}
func printResponse(v any) {
printed, _ := json.MarshalIndent(v, "", " ")
fmt.Fprintln(os.Stderr, string(printed))
}
func Scan(seq int, filename string) {
from, rcpts, auth, ip, qid, body, seen, err := cgp.Message(filename)
clientOne.Do(func() {
client = &http.Client{
Timeout: config.Timeout(),
Transport: &http.Transport{
DisableCompression: true,
},
}
})
msg, err := cgp.NewMessage(seq, filename)
if err != nil {
cgp.Failure(seq, qid, err)
cgp.Failure(seq, 0, err)
return
}
defer msg.Close()
if msg.IsSeen() {
cgp.OkSeen(seq, msg.QID)
return
}
if seen {
cgp.OkSeen(seq, qid)
return
}
req, err := http.NewRequest("POST", host, bytes.NewReader(body))
content := io.NewSectionReader(msg.File, msg.HdrPos, msg.Size-msg.HdrPos)
req, err := http.NewRequest("POST", config.Host(), content)
if err != nil {
cgp.Failure(seq, qid, err)
cgp.Failure(seq, msg.QID, err)
return
}
req.Header.Add("MTA-Tag", authservId)
req.Header.Add("MTA-Tag", config.AuthservId())
req.Header.Add("User-Agent", "rspamd-cgp")
req.Header.Add("From", from)
req.Header.Add("Queue-ID", strconv.Itoa(qid))
if len(auth) > 0 {
req.Header.Add("User", auth)
req.Header.Add("From", msg.From)
req.Header.Add("Queue-ID", strconv.Itoa(msg.QID))
if len(msg.Auth) > 0 {
req.Header.Add("User", msg.Auth)
}
if len(ip) > 0 {
req.Header.Add("IP", ip)
if len(msg.IP) > 0 {
req.Header.Add("IP", msg.IP)
}
for _, rcpt := range rcpts {
if len(msg.Helo) > 0 {
req.Header.Add("Helo", msg.Helo)
}
if len(msg.Hostname) > 0 {
req.Header.Add("Hostname", msg.Hostname)
}
for _, rcpt := range msg.Rcpts {
req.Header.Add("Rcpt", rcpt)
}
resp, err := client.Do(req)
if err != nil {
cgp.Failure(seq, qid, err)
cgp.Failure(seq, msg.QID, err)
return
}
defer resp.Body.Close()
rbody, err := ioutil.ReadAll(resp.Body)
if err != nil {
cgp.Failure(seq, qid, err)
var res RspamdResponse
var reader io.Reader = resp.Body
var debugBuf *bytes.Buffer
// Если Debug включен, копируем поток в буфер
if config.Debug() {
debugBuf = new(bytes.Buffer)
reader = io.TeeReader(resp.Body, debugBuf)
}
if err := json.NewDecoder(reader).Decode(&res); err != nil {
cgp.Failure(seq, msg.QID, err)
return
}
var res map[string]interface{}
if err := json.Unmarshal(rbody, &res); err != nil {
cgp.Failure(seq, qid, err)
if config.Debug() {
msg.PrintMsgInfo()
printResponse(debugBuf)
}
action := res.Action
if action == "" {
cgp.Failure(seq, msg.QID, fmt.Errorf("missing or invalid 'action' field"))
return
}
if debug {
printResponse(res)
}
opsum, caseinfo, casework, desc := makeOpSum(&res, action)
var headers []string
if _, ok := res["dkim-signature"]; ok {
headers = append(headers, strings.Join([]string{"DKIM-Signature: ", res["dkim-signature"].(string)}, ""))
if config.Outbound() {
headers = makeHeadersOutbound(&res)
} else {
headers = makeHeaders(&res)
}
if milter, ok := res["milter"]; ok {
if hdrs, ok := milter.(map[string]interface{})["add_headers"]; ok {
if reflect.TypeOf(hdrs).String() == "map[string]interface {}" {
for h, vh := range hdrs.(map[string]interface{}) {
if reflect.TypeOf(vh).String() == "map[string]interface {}" {
if v, ok := vh.(map[string]interface{})["value"].(string); ok && v != "" {
headers = append(headers, strings.Join([]string{h, v}, ": "))
}
}
}
}
cgp.Putline("* ", seq, " [", msg.QID, "]: Action: ", action,
"; Score: ", res.Score, "/", res.RequiredScore, "; Time elapsed: ", res.TimeReal)
var hci string
if opsum != nil {
cgp.Putline("* ", seq, " [", msg.QID, "]: Case: ", caseinfo, "; ", casework)
hci = headerCase + caseinfo
if !config.Outbound() {
headers = append(headers, hci)
}
}
action := res["action"]
cgp.Putline("* %d [%d]: Action: %s; Score: %.2f/%.2f; Time elapsed: %.3fs\n",
seq, qid, action, res["score"], res["required_score"], res["time_real"])
switch action {
case "no action":
if len(headers) > 0 {
cgp.AddHeader(seq, headers)
} else {
cgp.Ok(seq)
}
case "discard":
cgp.Discard(seq, qid, from, rcpts)
case "quarantine":
cgp.MirrorTo(seq, qid, mirrorTo, append(headers, headerJunkR), body, mirrorDiscard)
case "reject":
cgp.AddHeader(seq, append(headers, headerJunkR))
case "rewrite subject":
if subject, ok := res["subject"]; ok {
cgp.RewriteSubject(seq, append(headers, headerJunkA), subject.(string), qid, from, rcpts, body)
} else {
cgp.AddHeader(seq, append(headers, headerJunkA))
}
case "add header":
cgp.AddHeader(seq, append(headers, headerJunkA))
case "greylist":
fallthrough
case "soft reject":
cgp.AddHeader(seq, append(headers, headerJunkG))
default:
cgp.Failure(seq, qid, fmt.Errorf("Unknown action: %v", action))
}
procActionSwitch(seq, msg, opsum, &res, headers, hci, action, desc)
}
+83
View File
@@ -0,0 +1,83 @@
package rspamc
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.vsu.ru/ai/rspamd-cgp/config"
)
func TestScan_HttpRequestConstruction(t *testing.T) {
// Создаем тестовое сообщение
tmpDir := t.TempDir()
msgPath := filepath.Join(tmpDir, "100.msg")
// Важно: HdrPos будет 24 (после \n\n)
os.WriteFile(msgPath, []byte("P <s@domain.name>\nR <r@domain.name>\n\nFrom: s@domain.name\n\nBody"), 0644)
// Проверяем, что заголовки из CGP Message долетели до Rspamd
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CommuniGate хранит адреса в скобках, проверяем именно этот вариант
expectedFrom := "s@domain.name"
if r.Header.Get("From") != expectedFrom {
t.Errorf("Expected From header %s, got %s", expectedFrom, r.Header.Get("From"))
}
if r.Header.Get("MTA-Tag") != "test-mta" {
t.Errorf("Expected MTA-Tag, got %s", r.Header.Get("MTA-Tag"))
}
// Возвращаем мок-ответ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(RspamdResponse{
Action: "no action",
Score: 0.1,
})
}))
defer ts.Close()
// Настраиваем конфиг на этот сервер
conf := &config.Config{
Host: ts.URL,
AuthservId: "test-mta",
NotifyFrom: "postmaster@domain.name",
}
config.SetGlobal(conf)
// Запускаем скан.
// Мы не проверяем stdout (это требует сложного перехвата),
// но убеждаемся, что цепочка вызовов проходит без ошибок и паник.
Scan(1, msgPath)
}
func TestRspamdResponse_Unmarshalling(t *testing.T) {
// Проверяем, что наша структура корректно ест сложный JSON от Rspamd
rawJSON := `{
"action": "add header",
"score": 5.5,
"required_score": 7.0,
"milter": {
"add_headers": {
"X-Spam": {"value": "Yes"}
}
},
"symbols": {
"TEST_SYM": {"score": 1.2, "description": "some sym"}
}
}`
var res RspamdResponse
err := json.Unmarshal([]byte(rawJSON), &res)
if err != nil {
t.Fatalf("Unmarshall failed: %v", err)
}
if res.Milter.AddHeaders["X-Spam"].Value != "Yes" {
t.Error("Failed to parse milter headers")
}
if res.Symbols["TEST_SYM"].Score != 1.2 {
t.Error("Failed to parse symbols")
}
}
+63
View File
@@ -0,0 +1,63 @@
################################################################################
# rspamd-cgp config file
################################################################################
################################################################################
# Устанавливает значение authserv-id в заголовке Authentication-Results, RFC7001
# Если не задан, ему присваивается имя главного домена CommuniGate Pro.
#
authservid: mx.domain.name
################################################################################
# Адрес хоста и порт Rspamd, тайм-аут.
#
host: 127.0.0.1:11333
timeout: 15s
################################################################################
# Устанавливает значение заголовка From: в оповещениях.
#
notifyfrom: rspamd-cgp-notify@domain.name
################################################################################
# Секция описывает действия (actions) и дополнительную обработку для каждого
# действия. Здесь указываются действия, которым нужна дополнительная обработка.
#
# discard: # название действия
# description: "discard action" # короткое описание действия
# direction: in|out|both # применить действие для направления
# discard: true # доставить сообщение или выбросить
# notifyrcpts: false # оповещать ли получателей сообщения
# notifyto: [] # список получателей оповещения
# mirrorto: [aa@ba.ru, cc@dd.ru] # список получателей копии сообщения
#
actions:
discard:
description: "discard action"
discard: true
notifyrcpts: false
notifyto: []
mirrorto: [a@b.r, c@d.r]
################################################################################
# Секция описывает символы (SYMBOLs) и дополнительную обработку для каждого
# символа. Здесь указываются символы, которым нужна дополнительная обработка.
#
# FAKE_LOCAL_FROM: # название символа
# description: "" # по умолчанию берётся из ответа Rspamd
# direction: in|out|both # применить символ для направления
# discard: true # доставить сообщение или выбросить
# notifyrcpts: false # оповещать ли получателей сообщения
# notifyto: [] # список получателей оповещения
# mirrorto: [aa@ba.ru, cc@dd.ru] # список получателей копии сообщения
#
symbols:
FAKE_LOCAL_FROM:
discard: true
notifyto: [a@b.ru]
mirrorto: [c@d.ru]
FAKE_LOCAL_FROM_NAME:
discard: true
notifyto: [a@b.ru]
mirrorto: [c@d.ru]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+29
View File
@@ -0,0 +1,29 @@
package utils
import (
"unsafe"
)
func Bytes2string(b []byte) string {
if len(b) == 0 {
return ""
}
return unsafe.String(unsafe.SliceData(b), len(b))
}
func UniqueSliceElementsNonEmpty[T ~string](s []T) []T {
unique := make([]T, 0, len(s))
seen := make(map[T]bool, len(s))
for _, e := range s {
if len(e) > 0 {
if !seen[e] {
unique = append(unique, e)
seen[e] = true
}
}
}
return unique
}
+62
View File
@@ -0,0 +1,62 @@
package utils
import (
"testing"
)
func TestBytes2string(t *testing.T) {
t.Run("Valid conversion", func(t *testing.T) {
input := []byte("hello world")
got := Bytes2string(input)
if got != "hello world" {
t.Errorf("Expected 'hello world', got %q", got)
}
})
t.Run("Empty slice", func(t *testing.T) {
if got := Bytes2string([]byte{}); got != "" {
t.Errorf("Expected empty string, got %q", got)
}
if got := Bytes2string(nil); got != "" {
t.Errorf("Expected empty string for nil, got %q", got)
}
})
}
func TestUniqueSliceElementsNonEmpty(t *testing.T) {
tests := []struct {
name string
input []string
want []string
}{
{
name: "Duplicates and empty strings",
input: []string{"a", "b", "", "a", "c", "b", " "},
want: []string{"a", "b", "c", " "}, // Пробел не пустая строка
},
{
name: "All empty",
input: []string{"", "", ""},
want: []string{},
},
{
name: "Already unique",
input: []string{"one", "two"},
want: []string{"one", "two"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := UniqueSliceElementsNonEmpty(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("Length mismatch: got %d, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("At index %d: got %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}