45 Commits

Author SHA1 Message Date
ai 185637b990 форматирование строк 2026-03-11 01:17:10 +03:00
ai 7ba94253e3 форматирование оповещения 2026-03-10 12:49:01 +03:00
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
29 changed files with 2995 additions and 1110 deletions
+22 -21
View File
@@ -1,26 +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 Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions modification, are permitted provided that the following conditions are met:
are met:
1. Redistributions of source code must retain the above copyright 1. Redistributions of source code must retain the above copyright notice, this
notice, this list of conditions and the following disclaimer. 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 2. Redistributions in binary form must reproduce the above copyright notice,
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES this list of conditions and the following disclaimer in the documentation
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. and/or other materials provided with the distribution.
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.
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.
+42 -40
View File
@@ -1,58 +1,60 @@
[![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 6.x, *версия 2.0.0* ## Rspamd helper for CommuniGate Pro 6.x, *version 3.0.0*
### Введение ### Introduction
*Помощник* (внешний фильтр сообщений) **rspamd-cgp** используется для фильтрования спама. Он получает сообщения от *CommuniGate Pro* и передаёт их для обработки в *Rspamd*.<br/><br/> 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
* *Помощник* получает сообщение от *CommuniGate Pro* по протоколу *Интерфейса Внешнего Фильтра* и передаёт сообщение в *Rspamd*, используя *Rspamd protocol*. * **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.
* Если сообщение получено из аутентифицированного источника, *Помощник* передаёт заголовок *Auth:* в *Rspamd protocol*. * The *Helper* receives messages from *CommuniGate Pro* via the *External Filter Interface* protocol and transmits them to *Rspamd* using the *Rspamd protocol*.
* *Помощник* определяет *HELO/EHLO* протокола *SMTP* на основании первого заголовка *Received:* сообщения и передаёт заголовок *Helo:* в *Rspamd protocol*. * If a message is received from an authenticated source, the *Helper* passes the `Auth:` header in the *Rspamd protocol*.
* *Помощник* выполняет резольвинг ip-адреса источника сообщения, и, если резольвинг успешен, передаёт заголовок *Hostname:* в *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*.
* *Помощник* различает сообщения, полученные из внешних источников, и сгенерированные *CommuniGate Pro*. При передаче в *Rspamd* сгенерированных сообщений *Помощник* помечает их как полученные из доверенного источника. * 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.
* *Помощник* формирует заголовок *X-Junk-Score:* на основании *action*, пришедшего от *Rspamd*. Этот заголовок обрабатывается встроенными правилами *Управление Спамом*, которые настраиваются в пользовательском интерфейсе *CommuniGate Pro*. * 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.
* *Помощник* добавляет в сообщение все полученные от *Rspamd* заголовки. Решение о добавлении этих заголовков принимается на стороне *Rspamd*. * 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.
* При получении *action: rewrite subject* *Помощник* переписывает *Subject:* сообщения как указано в ответе *Rspamd*. Эта операция вызывает проход сообщения через *CommuniGate Pro PIPE*. * The *Helper* adds all headers received from *Rspamd* to the message. The decision to include these headers is made on the *Rspamd* side.
* В ряде случаев, чтобы избежать двойной обработки или зацикливания сообщений, *Помощник* добавляет в обработанное сообщение заголовок *X-Rspamd-Seen:*. * 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*.
* *Помощник* может выполнять дополнительную обработку сообщений на основании своего конфигурационного файла, используя данные из ответа *Rspamd*. Дополнительная обработка может быть применена к *действиям* и *символам*. Если для конкретного сообщения в конфигурационном файле *Помощника* совпали несколько *действий* и *символов*, для получения итогового результата они суммируются. * In certain scenarios, to prevent double processing or message looping, the *Helper* adds an `X-Rspamd-Seen:` header to the processed message.
* *Помощник* может обрабатывать исходящие сообщения. Это позволяет задействовать ряд модулей *Rspamd* для уменьшения ложных срабатываний (FP). * 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
Конфигурационный файл *Помощника* [rspamd-cgp.yml](rspamd-cgp.yml) по умолчанию находится в той же директории, что и исполняемый файл **rspamd-cgp**. При необходимости другое местоположение конфигурационного файла можно указать в командной строке. Возможные настройки *Помощника* подробно описаны в конфигурационном файле. 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.
> Ниже показаны настройки *Помощника* и *Правила* в интерфейсе *CommuniGate Pro*. > Below are the *Helper* settings and *Rules* within the *CommuniGate Pro* interface.
#### Для входящих сообщений #### For Inbound Messages
##### Установки -> Общее -> Помощники ##### Settings -> General -> Helpers
![](./helper-in.png) ![](./helper-in.png)
##### Установки -> Почта -> Правила -> RSPAMD_in ##### Settings -> Mail -> Rules -> RSPAMD_in
![](./rule-in.png) ![](./rule-in.png)
#### Для исходящих сообщений #### For Outbound Messages
##### Установки -> Общее -> Помощники ##### Settings -> General -> Helpers
![](./helper-out.png) ![](./helper-out.png)
##### Установки -> Почта -> Правила -> RSPAMD_out ##### Settings -> Mail -> Rules -> RSPAMD_out
![](./rule-out.png) ![](./rule-out.png)
#### Параметры командной строки #### Command Line Arguments
``` ```
Usage of rspamd-cgp: Usage of rspamd-cgp:
@@ -66,44 +68,44 @@
Run in debug mode Run in debug mode
-outbound -outbound
Outbound message flow processing Outbound message flow processing
-version
Print version and exit
``` ```
**config** **config**
Указывает альтернативный конфигурационный файл. Specifies an alternative configuration file.
**configdump** **configdump**
Выводит конфигурационный файл в форматированном виде. Outputs the configuration file in a formatted view.
**configtest** **configtest**
Проверяет синтаксическую корректность конфигурационного файла. Verifies the syntactic correctness of the configuration file.
**debug** **debug**
Выводит в форматированном виде ответ *Rspamd* (JSON). Может быть 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!!!***
использован для контроля возвращаемых *Rspamd* *символов* и других данных. Входной файл должен быть в формате файла очереди *CommuniGate Pro*. ***Применять только при запуске из командной строки!!!***
**outbound** **outbound**
Обрабатывает поток исходящих сообщений. Если исходящие сообщения отправляются на внешние MTA, в них не добавляются заголовки, являющиеся результатом проверки на спам. Какие именно сообщения обрабатываются таким образом определяется *Правилом* 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*.
*CommuniGate Pro*.
<br/> <br/>
### Лицензия ### License
BSD License, [LICENSE.md](LICENSE.md)<br/><br/> BSD License, [LICENSE.md](LICENSE.md)<br/><br/>
### Автор ### Author
Andrey Igoshin <<ai@vsu.ru>><br/><br/> Andrey Igoshin <<ai@vsu.ru>><br/><br/>
### Ссылки ### Links
* Репозиторий: <https://git.vsu.ru/ai/rspamd-cgp> * Repository: <https://git.vsu.ru/ai/rspamd-cgp>
* Сайт CommuniGate Pro: <https://communigatepro.ru> * CommuniGate Pro Website: <https://communigatepro.ru>
* Протокол Помощника: <https://old.communigatepro.ru/CommuniGatePro/russian/Helpers.html#Filters> * Helper Protocol: <https://doc.communigatepro.ru/russian/development/Helpers.html#Filters>
* Сайт Rspamd: <https://rspamd.com> * Rspamd Website: <https://rspamd.com>
* Протокол Rspamd: <https://rspamd.com/doc/developers/protocol.html> * Rspamd Protocol: <https://rspamd.com/doc/developers/protocol.html>
+112
View File
@@ -0,0 +1,112 @@
[![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>
+41 -14
View File
@@ -2,23 +2,50 @@
export GOPATH="${HOME}/src/rspamd-cgp" 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 export CGO_ENABLED=0
if [ "$1" == "fmt" ]; then case "$1" in
go fmt $2 "fmt")
elif [ "$1" == "get" ]; then go fmt ./...
go get $2 ;;
elif [ "$1" == "tidy" ]; then "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 go mod tidy
elif [ "$1" == "update" ]; then ;;
echo "update..." "update")
echo "Updating dependencies..."
go get -u ./... go get -u ./...
go mod tidy go mod tidy
elif [ "$1" == "vet" ]; then ;;
echo "vet..." "vet")
echo "Running static analysis..."
go vet ./... go vet ./...
else ;;
go build *)
fi echo "Building version $VERSION..."
go build -ldflags="$LDFLAGS" -o rspamd-cgp
;;
esac
+236 -281
View File
@@ -3,350 +3,305 @@ package cgp
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"os" "os"
"regexp"
"strconv" "strconv"
"strings" "sync"
"syscall" "syscall"
"git.vsu.ru/ai/rspamd-cgp/utils"
) )
const recvHdr = "Received:" type Appender interface {
const seenHdr = "X-Rspamd-Seen:" Append([]byte) []byte
const subjHdr = "Subject:" }
const submitDir = "Submitted"
var reHELO1 *regexp.Regexp const (
var reHELO2 *regexp.Regexp submitDir = "Submitted"
var reMD *regexp.Regexp )
var reSELF *regexp.Regexp
var reSMTP *regexp.Regexp
var protocol int
func init() { var (
// Received: from muus52.sndsy.ru ([185.235.30.52] verified) protocol int = 4
reHELO1 = regexp.MustCompile(`^Received: from (\S+) \(.* ?\[\S+\] verified\)`) stdoutFd int
// Received: from [10.19.5.40] (account edu@vsu.ru HELO edu.vsu.ru) )
reHELO2 = regexp.MustCompile(`^Received: from \[\S+\] \(.*(?: ?HELO (\S+))\)`)
reMD = regexp.MustCompile(`^\s+DomainName\s+=\s+([^;]+);`) var bufferPool = sync.Pool{
reSELF = regexp.MustCompile(`^S (?:<([^>]+)> )?(ALARM|DSN|GROUP|ICAL|LIST|LSTM|LSTO|MDN|PBX|PIPE|RULE|WEBUSER) \[0\.0\.0\.0\]`) New: func() any { return bytes.NewBuffer(make([]byte, 0, 4096)) },
reSMTP = regexp.MustCompile(`^S (?:<([^>]+)> )?(?:SMTP|HTTPU?|AIRSYNC|IMAP|RPOP|XIMSS) \[([0-9a-f.:]+)\]`)
} }
func AddHeader(seq int, headers []string) { 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 { for i, h := range headers {
Putline("%d ADDHEADER \"%s\" OK\n", seq, hdrs) replaceSpecCharsBuf(buf, h)
} else { if i < len(headers)-1 {
Putline("%d ADDHEADER \"%s\"\n", seq, hdrs) buf.WriteString("\\e")
} }
} }
func AddHeaderWithMirrorTo(seq int, qid int, to []string, discard bool, headers []string, buf.WriteString("\" OK")
body []byte, outbound bool) {
if !outbound || len(to) > 0 { res := buf.Bytes()
seenHdr, err := makeSeen(body) length := len(res)
if err != nil {
Failure(seq, qid, err) if length > 4096 || (length == 4096 && res[length-1] != '\n') {
Failure(seq, 0, fmt.Errorf("AddHeader: result exceeds 4k limit"))
return return
} }
headers = append(headers, seenHdr)
if length > 0 && res[length-1] != '\n' {
buf.WriteByte('\n')
res = buf.Bytes()
} }
hdrs := replaceSpecChars(strings.Join(headers, "\n")) syscall.Write(stdoutFd, res)
if protocol >= 4 {
if len(to) > 0 {
mirrorTo := make([]string, 0, len(to))
for _, m := range to {
mirrorTo = append(mirrorTo, "MIRRORTO \""+m+"\"")
} }
if discard { func Discard(seq int) {
Putline("%d ADDHEADER \"%s\" %s DISCARD\n", seq, hdrs, strings.Join(mirrorTo, " ")) var buf [64]byte
} else { b := strconv.AppendInt(buf[:0], int64(seq), 10)
Putline("%d ADDHEADER \"%s\" %s OK\n", seq, hdrs, strings.Join(mirrorTo, " ")) b = append(b, " DISCARD\n"...)
} syscall.Write(stdoutFd, b)
} else {
if discard {
Putline("%d ADDHEADER \"%s\" DISCARD\n", seq, hdrs)
} else {
Putline("%d ADDHEADER \"%s\" OK\n", seq, hdrs)
}
}
} else {
Putline("%d ADDHEADER \"%s\"\n", seq, hdrs)
}
}
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 Failure(seq, qid int, err error) { func Failure(seq, qid int, err error) {
Putline("* %d [%d]: %s\n", seq, qid, err) var buf [512]byte
Putline("%d FAILURE\n", seq) 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) { func Intf(seq int, ver string) {
protocol, _ = strconv.Atoi(ver) var buf [64]byte
Putline("%d INTF %d\n", seq, protocol) 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 MainDomain() (domain string, err error) { func MainDomain() (string, error) {
h, err := os.Open("Settings/Main.settings") h, err := os.Open("Settings/Main.settings")
if err != nil { if err != nil {
return return "", err
} }
defer h.Close() defer h.Close()
var line []byte rd := bufio.NewReader(h)
key := []byte("DomainName")
for m := bufio.NewReader(h); ; {
line, err = m.ReadSlice('\n')
if err != nil {
return
}
if s := reMD.FindAllStringSubmatch(string(line), -1); s != nil {
domain = s[0][1]
break
}
}
return
}
func Message(filename string) (from string, rcpts []string, auth string, ip string, helo string,
hostname string, qid int, body []byte, seen bool, err error) {
qid, err = strconv.Atoi((filename)[strings.LastIndexByte(filename, '/')+1 : strings.LastIndexByte(filename, '.')])
if err != nil {
return
}
h, err := os.Open(filename)
if err != nil {
return
}
defer h.Close()
var line []byte
var pos int64
m := bufio.NewReader(h)
for { for {
line, err := rd.ReadSlice('\n')
line, err = m.ReadSlice('\n') if err != nil && err != io.EOF {
if err != nil { return "", err
return
} }
pos += int64(len(line)) // Ищем вхождение DomainName
idxKey := bytes.Index(line, key)
if string(line) == "\n" { if idxKey == -1 {
if err == io.EOF {
break break
} }
continue
}
switch line[0] { // Проверяем, что перед ключом стоит разделитель (начало строки, пробел, { или ;)
case 'P': if idxKey > 0 {
s := strings.IndexByte(string(line), '<') prev := line[idxKey-1]
from = string(line[s : s+strings.IndexByte(string(line[s:]), '>')+1]) if prev != ' ' && prev != '\t' && prev != '{' && prev != ';' {
if err == io.EOF {
break
}
continue
}
}
case 'R': // Ищем '=' после ключа
s := strings.IndexByte(string(line), '<') lineAfterKey := line[idxKey+len(key):]
rcpts = append(rcpts, string(line[s:s+strings.IndexByte(string(line[s:]), '>')+1])) idxEq := bytes.IndexByte(lineAfterKey, '=')
if idxEq == -1 {
if err == io.EOF {
break
}
continue
}
case 'S': // Ищем ';' после '='
if s := reSMTP.FindAllStringSubmatch(string(line), -1); s != nil { idxSemi := bytes.IndexByte(lineAfterKey[idxEq:], ';')
auth = s[0][1] if idxSemi == -1 {
ip = s[0][2] if err == io.EOF {
hostname = getHostname(ip) break
} else if s := reSELF.FindAllStringSubmatch(string(line), -1); s != nil { }
if len(s[0][1]) > 0 { continue
auth = s[0][1] }
// Извлекаем и чистим значение
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
}
}
return "", fmt.Errorf("DomainName not found in settings")
}
func MirrorTo(seq int, m *Message, to []string, headers []string, discard bool) {
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
// SEQ [ADDHEADER "h1\eh2"] [MIRRORTO "rcpt"] {DISCARD|OK}
buf.WriteString(strconv.Itoa(seq))
if len(headers) > 0 {
buf.WriteString(" ADDHEADER \"")
for i, h := range headers {
replaceSpecCharsBuf(buf, h)
if i < len(headers)-1 {
buf.WriteString("\\e")
}
}
buf.WriteString("\"")
}
for _, rcpt := range to {
buf.WriteString(" MIRRORTO \"")
replaceSpecCharsBuf(buf, rcpt)
buf.WriteString("\"")
}
if discard {
buf.WriteString(" DISCARD")
} else { } else {
auth = s[0][2] + "@trusted" buf.WriteString(" OK")
}
ip = "127.2.4.7"
}
}
} }
seen, err = isSeen(m) res := buf.Bytes()
if err != nil { if len(res) > 4096 {
Failure(seq, m.QID, fmt.Errorf("MirrorTo: result exceeds 4k limit (%d bytes)", len(res)))
return return
} }
if seen { if len(res) > 0 && res[len(res)-1] != '\n' {
return buf.WriteByte('\n')
res = buf.Bytes()
} }
fi, err := h.Stat() syscall.Write(stdoutFd, res)
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
}
helo = getHelo(body)
rcpts = utils.UniqueSliceElementsNonEmpty(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
} }
func Ok(seq int) { 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) { func OkSeen(seq, qid int) {
Putline("* %d [%d]: Already seen by Rspamd\n", seq, qid) var buf [128]byte
Putline("%d OK\n", seq) 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{}) { func Putline(a ...any) {
s := fmt.Sprintf(format, a...) var stackBuf [256]byte
syscall.Write(int(os.Stdout.Fd()), []byte(s)) 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) { func Reject(seq int) {
Putline("%d REJECT Try again later\n", seq) var buf [64]byte
} b := strconv.AppendInt(buf[:0], int64(seq), 10)
b = append(b, " REJECT Try again later\n"...)
func RewriteSubject(seq int, headers []string, subject string, qid int, from string, rcpts []string, body []byte) (err error) { syscall.Write(stdoutFd, b)
var firstRecv bool = true
var m *bufio.Reader
var hdr string
var pos int = 0
filename := submitDir + "/" + strconv.Itoa(qid) + "rs.sub"
filetemp := strings.Replace(filename, "sub", "tmp", 1)
fh, err := os.Create(filetemp)
if err != nil {
goto fin
}
defer fh.Close()
_, err = fh.WriteString("Return-Path: " + from + "\n")
if err != nil {
goto fin
}
for _, rcpt := range rcpts {
_, err = fh.WriteString("Envelope-To: " + rcpt + "\n")
if err != nil {
goto fin
}
}
_, err = fh.WriteString(strings.Join(headers, "\n") + "\n")
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
}
pos += len(hdr)
if hdr == "\n" {
// конец RFC5322 заголовка
_, err = fh.WriteString(hdr)
if err != nil {
goto fin
}
break
}
if firstRecv && strings.HasPrefix(hdr, recvHdr) {
bs := sha256.Sum224([]byte(utils.NoSpace(hdr)))
sum := hex.EncodeToString(bs[:])
_, err = fh.WriteString(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(subjHdr + " " + subject + "\n")
if err != nil {
goto fin
}
continue
}
_, err = fh.WriteString(hdr)
if err != nil {
goto fin
}
}
_, err = fh.Write(body[pos:])
if err != nil {
goto fin
}
if err = fh.Close(); err != nil {
goto fin
}
err = os.Rename(filetemp, filename)
fin:
return
} }
+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)
}
})
}
+69 -24
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"net" "net"
"net/netip" "net/netip"
"regexp"
"strings" "strings"
"time" "time"
@@ -92,49 +91,99 @@ var getHostname = func() func(addr string) (hostname string) {
} }
}() }()
var IsValidDomain = func() func(domain string) bool { func IsValidDomain(domain string) bool {
rx := regexp.MustCompile(`^(?i)[a-z0-9-]+(\.[a-z0-9-]+)+\.?$`) if len(domain) == 0 || len(domain) > 253 {
return func(domain string) bool { return false
return rx.MatchString(domain) }
// 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) { func lookupAddr(ipAddr netip.Addr) (hostname string) {
r := &net.Resolver{PreferGo: true} r := &net.Resolver{PreferGo: true}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
names, err := r.LookupAddr(ctx, ipAddr.String()) names, err := r.LookupAddr(ctx, ipAddr.String())
cancel()
if err != nil { if err != nil {
return return
} }
if len(names) == 1 { if len(names) == 1 {
hostname = names[0] hostname = strings.TrimSuffix(names[0], ".")
} else if len(names) > 1 {
found := make([]string, 0, len(names))
for _, name := range names {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ips, err := r.LookupHost(ctx, name)
cancel()
if err != nil {
return 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 { for _, ip := range ips {
ipTest, err := netip.ParseAddr(ip) ipTest, err := netip.ParseAddr(ip)
if err != nil { if err != nil {
continue continue
} }
if ipTest == ipAddr { if ipTest == ipAddr {
found = append(found, name) ch <- result{name}
break 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) { switch len(found) {
@@ -151,11 +200,7 @@ func lookupAddr(ipAddr netip.Addr) (hostname string) {
// в исходный ip, выбираем самый короткий. // в исходный ip, выбираем самый короткий.
hostname = findShorterItem(filterNames(found)) hostname = findShorterItem(filterNames(found))
} }
}
if len(hostname) > 0 {
hostname = strings.TrimSuffix(hostname, ".") hostname = strings.TrimSuffix(hostname, ".")
}
return 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)
}
}
})
}
+90 -223
View File
@@ -3,246 +3,113 @@ package cgp
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io"
"strings" "strings"
"unicode/utf8"
"git.vsu.ru/ai/rspamd-cgp/utils"
) )
func getHeader(m *bufio.Reader) (hdr string, err error) { func extractAngle(line []byte) (string, bool) {
s := strings.IndexByte(string(line), '<')
var c byte if s < 0 {
var line []byte return "", false
}
var b strings.Builder e := strings.IndexByte(string(line[s:]), '>')
b.Grow(384) if e < 0 {
return "", false
}
return string(line[s+1 : s+e]), true
}
func getHeader(m *bufio.Reader, buf *bytes.Buffer) error {
for { for {
c, err = m.ReadByte() line, err := m.ReadSlice('\n')
if err == io.EOF { if err != nil && err != bufio.ErrBufferFull {
if c == 0 { if len(line) > 0 {
break buf.Write(line)
} else {
err = nil
} }
} return err
if err != nil {
return
} }
if b.Len() == 0 { buf.Write(line)
if c == ' ' || c == '\t' { if err == bufio.ErrBufferFull {
err = m.UnreadByte() if buf.Len() > 64*1024 {
if err != nil { return fmt.Errorf("header too long")
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 getHelo(body []byte) (helo string) {
var hdr string
var err error
m := bufio.NewReader(bytes.NewReader(body))
for {
hdr, err = m.ReadString('\n')
if err == io.EOF {
err = nil
if len(hdr) == 0 {
break
}
}
if err != nil {
return
}
if hdr == "\n" {
// конец RFC5322 заголовка
break
}
if strings.HasPrefix(hdr, recvHdr) {
if s := reHELO1.FindAllStringSubmatch(hdr, -1); s != nil {
helo = s[0][1]
} else if s := reHELO2.FindAllStringSubmatch(hdr, -1); s != nil {
helo = s[0][1]
}
break
}
}
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) {
bs := sha256.Sum224([]byte(utils.NoSpace(hdr)))
sum := hex.EncodeToString(bs[:])
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) {
bs := sha256.Sum224([]byte(utils.NoSpace(hdr)))
seenhdr = seenHdr + " " + hex.EncodeToString(bs[:])
break
}
}
return
}
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 continue
}
case rune('\n'): if isHeaderEnd(line) {
// replace \n -> \\e (CGP End-of-Line) return nil
sb.WriteString("\\e") }
case rune('\t'): next, err := m.Peek(1)
// replace \t -> \\t (CGP Tab) if err != nil {
sb.WriteString("\\t") return err
}
case rune('"'): if next[0] != ' ' && next[0] != '\t' {
// replace \" -> \\" (CGP quote) return nil
sb.WriteString("\\\"") }
}
}
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: default:
sb.WriteRune(symbol) if r < utf8.RuneSelf {
buf.WriteByte(byte(r))
} else {
buf.WriteRune(r)
}
} }
} }
return sb.String()
} }
+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)
}
})
}
+91 -72
View File
@@ -1,92 +1,111 @@
package cgp package cgp
import ( import (
"bytes" "bufio"
"io"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
) )
func NotifyTo(seq int, qid int, to []string, header string, from string, rcpts []string, func NotifyTo(seq int, m *Message, to []string, header string, notifyfrom string, desc string) {
body []byte, notifyfrom string, desc string) { mailid := strconv.Itoa(m.QID)
sSeq := strconv.Itoa(seq)
date := time.Now().Format(time.RFC1123Z)
mailid := strconv.Itoa(qid)
boundary := "nextPart" + mailid + "." + strconv.Itoa(seq)
off := bytes.Index(body, []byte("\n\n"))
var sb strings.Builder
sb.Grow(2048 + off)
sb.WriteString("Return-Path: <>\n")
for _, rcpt := range to {
sb.WriteString("Envelope-To: " + rcpt + "\n")
}
sb.WriteString("From: " + notifyfrom + "\n")
sb.WriteString("Subject: Notify Case " + mailid + "\n")
sb.WriteString("Date: " + date + "\n")
sb.WriteString(header + "\n")
sb.WriteString("MIME-Version: 1.0\n")
sb.WriteString("Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\n")
sb.WriteString("\n")
sb.WriteString("This is a multi-part message in MIME format.\n\n")
sb.WriteString("--" + boundary + "\n")
sb.WriteString("Content-Type: text/plain; charset=UTF-8; format=flowed\n")
sb.WriteString("Content-Transfer-Encoding: 8bit\n")
sb.WriteString("\n")
sb.WriteString("mail id: " + mailid + "\n")
sb.WriteString("mail from: " + from + "\n")
sb.WriteString("rcpt to: " + rcpts[0] + "\n")
for _, rcpt := range rcpts[1:] {
sb.WriteString(" " + rcpt + "\n")
}
sb.WriteString("date: " + date + "\n")
sb.WriteString("\n")
sb.WriteString("case conditions:\n")
sb.WriteString("----------------\n")
sb.WriteString(desc + "\n")
sb.WriteString("\n\n\n")
sb.WriteString("--" + boundary + "\n")
sb.WriteString("Content-Disposition: attachment; filename=\"headers\"\n")
sb.WriteString("Content-Type: text/plain; charset=UTF-8\n")
sb.WriteString("Content-Transfer-Encoding: 8bit\n")
sb.WriteString("\n")
sb.Write(body[:off+1])
sb.WriteString("\n")
sb.WriteString("--" + boundary + "--\n")
filename := submitDir + "/" + mailid + "no.sub" filename := submitDir + "/" + mailid + "no.sub"
filetemp := strings.Replace(filename, "sub", "tmp", 1) filetemp := submitDir + "/" + mailid + "no.tmp"
// Локальная функция-обертка для изоляции ресурсов (File, Writer)
err := func() error {
fh, err := os.Create(filetemp) fh, err := os.Create(filetemp)
if err != nil { if err != nil {
goto fin return err
} }
defer fh.Close() defer fh.Close()
_, err = fh.WriteString(sb.String()) 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("<>")
}
w.WriteString("\n")
if len(m.Rcpts) > 0 {
w.WriteString("rcpt 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 { if err != nil {
goto fin os.Remove(filetemp)
Putline("* ", sSeq, " [", mailid, "]: notify: ", err)
return
} }
if err = fh.Close(); err != nil { if err = os.Rename(filetemp, filename); err != nil {
goto fin os.Remove(filetemp)
} Putline("* ", sSeq, " [", mailid, "]: notify rename error: ", err)
err = os.Rename(filetemp, filename)
fin:
if err != nil {
Putline("* %d [%d]: notify: %s\n", seq, qid, 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")
}
}
+84 -35
View File
@@ -12,9 +12,16 @@ import (
"git.vsu.ru/ai/rspamd-cgp/cgp" "git.vsu.ru/ai/rspamd-cgp/cgp"
) )
// Эти значения будут перезаписаны при сборке через -ldflags
var (
Version = "dev"
Commit = "none"
BuildTime = "unknown"
)
type Operation struct { type Operation struct {
Description string Description string
Direction string Direction Direction
Discard bool Discard bool
MirrorTo []string MirrorTo []string
NotifyRcpts bool NotifyRcpts bool
@@ -23,51 +30,72 @@ type Operation struct {
type Config struct { type Config struct {
AuthservId string AuthservId string
Debug bool
Outbound bool
Host string `default:"localhost:11333"` Host string `default:"localhost:11333"`
Timeout time.Duration `default:"15s"` Timeout time.Duration `default:"15s"`
NotifyFrom string `required:"true"` NotifyFrom string `required:"true"`
Actions map[string]*Operation Actions map[string]*Operation
Symbols map[string]*Operation Symbols map[string]*Operation
debug bool
outbound bool
showVersion bool
} }
func New() *Config { var configInstance *Config
config := new(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])) dir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
var configfile string var configfile string
var configdump bool var configdump bool
var configtest bool var configtest bool
var debug bool var err error
var outbound bool
flag.StringVar(&configfile, "config", dir+"/rspamd-cgp.yml", "Set configuration file") fs.StringVar(&configfile, "config", dir+"/rspamd-cgp.yml", "Set configuration file")
flag.BoolVar(&configdump, "configdump", false, "Perform configuration file dump") fs.BoolVar(&configdump, "configdump", false, "Perform configuration file dump")
flag.BoolVar(&configtest, "configtest", false, "Perform configuration file test") fs.BoolVar(&configtest, "configtest", false, "Perform configuration file test")
flag.BoolVar(&debug, "debug", false, "Run in debug mode") fs.BoolVar(&c.debug, "debug", false, "Run in debug mode")
flag.BoolVar(&outbound, "outbound", false, "Outbound message flow processing") fs.BoolVar(&c.outbound, "outbound", false, "Outbound message flow processing")
flag.Parse() fs.BoolVar(&c.showVersion, "version", false, "print version and exit")
err := configor.Load(config, configfile) if err = fs.Parse(args); err != nil {
return nil, err
}
err = configor.Load(c, configfile)
if err != nil { if err != nil {
fmt.Println("config:", err) return nil, err
os.Exit(1)
}
if debug {
config.Debug = debug
}
if outbound {
config.Outbound = outbound
} }
if configdump { if configdump {
dumpConfig(config) dumpConfig(c)
if err = validateConfig(config); err != nil { if err = validateConfig(c); err != nil {
fmt.Println("config:", err) fmt.Println("config:", err)
os.Exit(1) os.Exit(1)
} }
@@ -75,7 +103,7 @@ func New() *Config {
} }
if configtest { if configtest {
if err = validateConfig(config); err != nil { if err = validateConfig(c); err != nil {
fmt.Println("config:", err) fmt.Println("config:", err)
os.Exit(1) os.Exit(1)
} else { } else {
@@ -84,16 +112,37 @@ func New() *Config {
} }
} }
if config.AuthservId == "" { if c.AuthservId == "" {
if config.AuthservId, err = cgp.MainDomain(); err != nil { if c.AuthservId, err = cgp.MainDomain(); err != nil {
fmt.Println("Can not detect Main Domain:", err) return nil, fmt.Errorf("can not detect Main Domain: %v", err)
os.Exit(1)
} }
} }
setOpDefaultDirection(config) c.Host = "http://" + c.Host + "/checkv2"
config.Host = "http://" + config.Host + "/checkv2" return c, nil
}
return config
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
}
+13 -34
View File
@@ -9,42 +9,31 @@ import (
var reMail *regexp.Regexp var reMail *regexp.Regexp
func dumpConfig(config *Config) { func dumpConfig(c *Config) {
yml, err := yaml.Marshal(config) yml, err := yaml.Marshal(c)
if err != nil { if err != nil {
fmt.Println("config:", err) fmt.Println("config:", err)
} else { } else {
fmt.Println(string(yml)) fmt.Print(string(yml))
fmt.Println("debug:", c.debug)
fmt.Println("outbound:", c.outbound)
fmt.Println("showVersion:", c.showVersion)
} }
} }
func setOpDefaultDirection(config *Config) { func validateConfig(c *Config) (err error) {
setDir := func(entry map[string]*Operation) {
for _, op := range entry {
if len(op.Direction) == 0 {
op.Direction = "both"
}
}
}
setDir(config.Actions)
setDir(config.Symbols)
}
func validateConfig(config *Config) (err error) {
reMail = regexp.MustCompile(`^\S+?@\S+$`) reMail = regexp.MustCompile(`^\S+?@\S+$`)
if err = validateConfigOp(config.NotifyFrom); err != nil { if err = validateConfigOp(c.NotifyFrom); err != nil {
return return
} }
if err = validateConfigEntry(config.Actions); err != nil { if err = validateConfigEntry(c.Actions); err != nil {
return return
} }
if err = validateConfigEntry(config.Symbols); err != nil { if err = validateConfigEntry(c.Symbols); err != nil {
return return
} }
@@ -55,9 +44,9 @@ func validateConfigEntry(entry map[string]*Operation) (err error) {
for e, op := range entry { for e, op := range entry {
if err = validateDirection(op.Direction); err != nil { // Проверка диапазона Direction
err = fmt.Errorf("%s: Direction: %v", e, err) if op.Direction < DirBoth || op.Direction > DirOut {
break return fmt.Errorf("%s: invalid direction index: %d", e, op.Direction)
} }
if err = validateConfigOps(op.NotifyTo); err != nil { if err = validateConfigOps(op.NotifyTo); err != nil {
@@ -87,19 +76,9 @@ func validateConfigOps(mail []string) (err error) {
for _, m := range mail { for _, m := range mail {
if err = validateConfigOp(m); err != nil { if err = validateConfigOp(m); err != nil {
err = fmt.Errorf("invalid mail: %s", m)
break break
} }
} }
return return
} }
func validateDirection(dir string) (err error) {
if dir != "both" && dir != "in" && dir != "out" && dir != "" {
err = fmt.Errorf("unknown direction: %s", dir)
}
return
}
+4 -6
View File
@@ -1,18 +1,16 @@
module git.vsu.ru/ai/rspamd-cgp module git.vsu.ru/ai/rspamd-cgp
go 1.23 go 1.25
require ( require (
github.com/cespare/xxhash/v2 v2.3.0
github.com/jinzhu/configor v1.2.2 github.com/jinzhu/configor v1.2.2
github.com/json-iterator/go v1.1.12
github.com/maypok86/otter v1.2.4 github.com/maypok86/otter v1.2.4
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/BurntSushi/toml v1.4.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/dolthub/maphash v0.1.0 // indirect github.com/dolthub/maphash v0.1.0 // indirect
github.com/gammazero/deque v1.0.0 // indirect github.com/gammazero/deque v1.2.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
) )
+6 -15
View File
@@ -1,29 +1,20 @@
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA= github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8= github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
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/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+38 -19
View File
@@ -2,55 +2,74 @@ package main
import ( import (
"bufio" "bufio"
"fmt" "bytes"
"io" "io"
"os" "os"
"strconv"
"git.vsu.ru/ai/rspamd-cgp/cgp" "git.vsu.ru/ai/rspamd-cgp/cgp"
"git.vsu.ru/ai/rspamd-cgp/config" "git.vsu.ru/ai/rspamd-cgp/config"
"git.vsu.ru/ai/rspamd-cgp/rspamc" "git.vsu.ru/ai/rspamd-cgp/rspamc"
"git.vsu.ru/ai/rspamd-cgp/utils"
) )
func main() { func main() {
var arg, cmd string if err := cgp.InitStdoutFd(); err != nil {
var seq int os.Stderr.WriteString("failed to init CGP stdout\n")
var line []byte os.Exit(1)
var err error }
var n int
config := config.New() 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) in := bufio.NewReader(os.Stdin)
finalize: loop:
for { for {
line, err = in.ReadSlice('\n') line, err := in.ReadSlice('\n')
if err != nil { if err != nil {
cgp.Putline("* error: %s\n", err) if err != io.EOF {
break finalize cgp.Putline("* stdin error: ", err)
}
break loop
} }
n, err = fmt.Sscan(string(line), &seq, &cmd, &arg) parts := bytes.Fields(line)
if err != nil && err != io.EOF { n := len(parts)
cgp.Putline("* error: %s\n", err) if n < 2 {
continue continue
} }
seq, err := strconv.Atoi(utils.Bytes2string(parts[0]))
if err != nil {
continue
}
cmd := utils.Bytes2string(parts[1])
switch { switch {
case cmd == "FILE" && n == 3: case cmd == "FILE" && n == 3:
go rspamc.Scan(config, seq, arg) go rspamc.Scan(seq, string(parts[2]))
case cmd == "INTF" && n == 3: case cmd == "INTF" && n == 3:
cgp.Intf(seq, arg) cgp.Intf(seq, utils.Bytes2string(parts[2]))
case cmd == "QUIT" && n == 2: case cmd == "QUIT" && n == 2:
cgp.Ok(seq) cgp.Ok(seq)
break finalize break loop
default: 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)
}
+198 -138
View File
@@ -1,73 +1,76 @@
package rspamc package rspamc
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"os" "os"
"reflect"
"strconv" "strconv"
"strings" "strings"
json "github.com/json-iterator/go"
"git.vsu.ru/ai/rspamd-cgp/cgp" "git.vsu.ru/ai/rspamd-cgp/cgp"
"git.vsu.ru/ai/rspamd-cgp/config" "git.vsu.ru/ai/rspamd-cgp/config"
"git.vsu.ru/ai/rspamd-cgp/utils" "git.vsu.ru/ai/rspamd-cgp/utils"
) )
func filterLocalRcpts(rcpts []string, res map[string]interface{}) []string { 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)) filtered := make([]string, 0, len(rcpts))
if v, ok := res["symbols"].(map[string]interface{})["RCPTS_DOMAINS_LOCAL"]; ok { if res.Symbols == nil {
if domains, ok := v.(map[string]interface{})["options"]; ok { return filtered
}
v, ok := res.Symbols["RCPTS_DOMAINS_LOCAL"]
if !ok || v == nil || len(v.Options) == 0 {
return filtered
}
for _, rcpt := range rcpts { for _, rcpt := range rcpts {
for _, domain := range domains.([]interface{}) { at := strings.IndexByte(rcpt, '@')
if rcpt[strings.IndexByte(rcpt, '@')+1:len(rcpt)-1] == domain.(string) { if at < 0 {
continue
}
rcptDomain := rcpt[at+1 : len(rcpt)-1]
for _, domain := range v.Options {
if rcptDomain == domain {
filtered = append(filtered, rcpt) filtered = append(filtered, rcpt)
break break
} }
} }
} }
}
}
return filtered return filtered
} }
func isOpAccept(direction string, outbound bool) (a bool) { func isOpAccept(dir config.Direction, outbound bool) bool {
switch dir {
if outbound { case config.DirBoth:
return true
if direction == "out" || direction == "both" { case config.DirOut:
a = true return outbound
} case config.DirIn:
return !outbound
} else { default:
return false
if direction == "in" || direction == "both" {
a = true
} }
} }
return func makeHeaders(res *RspamdResponse) (headers []string) {
if res.DKIMSignature != "" {
headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
} }
func makeHeaders(res map[string]interface{}) (headers []string) { if res.Milter != nil && res.Milter.AddHeaders != nil {
for h, vh := range res.Milter.AddHeaders {
if _, ok := res["dkim-signature"]; ok { if vh.Value != "" {
headers = append(headers, "DKIM-Signature: "+res["dkim-signature"].(string)) headers = append(headers, h+": "+vh.Value)
}
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, h+": "+v)
}
}
}
} }
} }
} }
@@ -75,23 +78,21 @@ func makeHeaders(res map[string]interface{}) (headers []string) {
return return
} }
func makeHeadersOutbound(res map[string]interface{}) (headers []string) { func makeHeadersOutbound(res *RspamdResponse) (headers []string) {
if res.DKIMSignature != "" {
if _, ok := res["dkim-signature"]; ok { headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
headers = append(headers, "DKIM-Signature: "+res["dkim-signature"].(string))
} }
return return
} }
func makeOpSum(conf *config.Config, res map[string]interface{}, action string) (*config.Operation, string, string, string) { func makeOpSum(res *RspamdResponse, action string) (*config.Operation, string, string, string) {
var casea []string var casea []string
var desca []string var desca []string
opsum := new(config.Operation) opsum := new(config.Operation)
if op, ok := conf.Actions[action]; ok { // Обработка Action
if isOpAccept(conf.Actions[action].Direction, conf.Outbound) { if op, ok := config.Action(action); ok {
if isOpAccept(op.Direction, config.Outbound()) {
opsum.Discard = op.Discard opsum.Discard = op.Discard
opsum.MirrorTo = op.MirrorTo opsum.MirrorTo = op.MirrorTo
opsum.NotifyRcpts = op.NotifyRcpts opsum.NotifyRcpts = op.NotifyRcpts
@@ -104,15 +105,17 @@ func makeOpSum(conf *config.Config, res map[string]interface{}, action string) (
desca = append(desca, action) desca = append(desca, action)
} }
if conf.Debug { if config.Debug() {
printSelectedOp("Action", action, conf.Actions[action].Direction, conf.Outbound) printSelectedOp("Action", action, op.Direction, config.Outbound())
} }
} }
} }
for symbol, op := range conf.Symbols { // Обработка Symbols
if isOpAccept(conf.Symbols[symbol].Direction, conf.Outbound) { if res.Symbols != nil {
if v, ok := res["symbols"].(map[string]interface{})[symbol]; ok { 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.Discard = opsum.Discard || op.Discard
opsum.MirrorTo = append(opsum.MirrorTo, op.MirrorTo...) opsum.MirrorTo = append(opsum.MirrorTo, op.MirrorTo...)
opsum.NotifyRcpts = opsum.NotifyRcpts || op.NotifyRcpts opsum.NotifyRcpts = opsum.NotifyRcpts || op.NotifyRcpts
@@ -121,29 +124,27 @@ func makeOpSum(conf *config.Config, res map[string]interface{}, action string) (
if len(op.Description) > 0 { if len(op.Description) > 0 {
desca = append(desca, symbol+": "+op.Description) desca = append(desca, symbol+": "+op.Description)
} else { } else if v.Description != "" {
if desc, ok := v.(map[string]interface{})["description"]; ok { desca = append(desca, symbol+": "+v.Description)
desca = append(desca, symbol+": "+desc.(string))
} else { } else {
desca = append(desca, symbol) desca = append(desca, symbol)
} }
}
if conf.Debug { if config.Debug() {
printSelectedOp("Symbol", symbol, conf.Symbols[symbol].Direction, conf.Outbound) printSelectedOp("Symbol", symbol, op.Direction, config.Outbound())
}
} }
} }
} }
} }
if len(casea) > 0 { if len(casea) > 0 {
var sb strings.Builder var sb strings.Builder
sb.Grow(256) sb.Grow(256)
// Формирования casework
sb.WriteString("discard ") sb.WriteString("discard ")
sb.WriteString(strconv.FormatBool(opsum.Discard)) sb.WriteString(strconv.FormatBool(opsum.Discard))
sb.WriteString("; notifyrcpts ") sb.WriteString("; notifyrcpts ")
sb.WriteString(strconv.FormatBool(opsum.NotifyRcpts)) sb.WriteString(strconv.FormatBool(opsum.NotifyRcpts))
@@ -165,110 +166,169 @@ func makeOpSum(conf *config.Config, res map[string]interface{}, action string) (
return nil, "", "", "" return nil, "", "", ""
} }
func printMsgInfo(from string, rcpts []string, auth string, ip string, helo string, hostname string, func printSelectedOp(optype, opname string, dir config.Direction, outbound bool) {
qid int, seen bool) {
fmt.Fprintln(os.Stderr, "from: ", from)
fmt.Fprintln(os.Stderr, "rcpts: ", rcpts)
fmt.Fprintln(os.Stderr, "ip: ", ip)
fmt.Fprintln(os.Stderr, "helo: ", helo)
fmt.Fprintln(os.Stderr, "hostname:", hostname)
fmt.Fprintln(os.Stderr, "qid: ", qid)
if len(auth) > 0 {
fmt.Fprintln(os.Stderr, "auth: ", auth)
} else {
fmt.Fprintln(os.Stderr, "auth: not authenticated")
}
fmt.Fprintln(os.Stderr, "seen: ", seen)
fmt.Fprintln(os.Stderr, "")
}
func printSelectedOp(optype, opname, direction string, outbound bool) {
if outbound { if outbound {
fmt.Fprintf(os.Stderr, "%s '%s' selected for outbound flow: direction %s\n", optype, opname, direction) fmt.Fprintf(os.Stderr, "%s '%s' selected for outbound flow: direction %s\n", optype, opname, dir.String())
} else { } else {
fmt.Fprintf(os.Stderr, "%s '%s' selected for inbound flow: direction %s\n", optype, opname, direction) fmt.Fprintf(os.Stderr, "%s '%s' selected for inbound flow: direction %s\n", optype, opname, dir.String())
} }
} }
func printResponse(v any) { func printResponse(debugBuf *bytes.Buffer) {
printed, _ := json.MarshalIndent(v, "", " ") var pretty bytes.Buffer
fmt.Fprintln(os.Stderr, string(printed), "\n") if err := json.Indent(&pretty, debugBuf.Bytes(), "", " "); err == nil {
os.Stderr.Write(pretty.Bytes())
os.Stderr.Write([]byte("\n"))
}
} }
func procAction(seq int, qid int, opsum *config.Operation, res map[string]interface{}, // procAction обрабатывает вердикт Rspamd согласно BNF:
headers []string, hci string, from string, rcpts []string, body []byte, notifyfrom string, // SEQ [ADDHEADER "h"] [MIRRORTO "a"] {OK|DISCARD}
desc string, outbound bool, t int) { 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 { if opsum != nil {
discard = opsum.Discard
if opsum.Discard { actualMirror = opsum.MirrorTo
cgp.Putline("* %d [%d]: Action: discard; from %s, rcpts %s\n", seq, qid, from, strings.Join(rcpts, ","))
cgp.AddHeaderWithMirrorTo(seq, qid, opsum.MirrorTo, opsum.Discard, headers, body, outbound)
} else { } else {
to := utils.DiffSlice(opsum.MirrorTo, rcpts) discard = (t == 2)
cgp.AddHeaderWithMirrorTo(seq, qid, to, opsum.Discard, headers, body, outbound)
} }
if len(opsum.NotifyTo) > 0 || opsum.NotifyRcpts { // Валидация MirrorTo (Seen tag)
if opsum.NotifyRcpts { if len(actualMirror) > 0 {
to := make([]string, 0, len(opsum.NotifyTo)+len(rcpts)) if seenHdr := msg.MakeSeen(); seenHdr != "" {
to = append(to, opsum.NotifyTo...) headers = append(headers, seenHdr)
rcpts_filtered := filterLocalRcpts(rcpts, res)
to = append(to, rcpts_filtered...)
to = utils.UniqueSliceElementsNonEmpty(to)
cgp.NotifyTo(seq, qid, to, hci, from, rcpts, body, notifyfrom, desc)
} else { } else {
cgp.NotifyTo(seq, qid, opsum.NotifyTo, hci, from, rcpts, body, notifyfrom, desc) cgp.Putline("* ", seq, " [", msg.QID, "]: warning: MirrorTo skipped (no Seen tag)")
actualMirror = nil
} }
} }
} else { // Оптимизация ADDHEADER
if discard && len(actualMirror) == 0 {
headers = nil
}
switch t { // Финальный ответ
case 0: cgp.MirrorTo(seq, msg, actualMirror, headers, discard)
if len(headers) > 0 { }
// 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) cgp.AddHeader(seq, headers)
} else { return
cgp.Ok(seq)
} }
case 1: // ШТАТНЫЙ РЕЖИМ: Метка есть, можем безопасно делать MIRRORTO
cgp.AddHeader(seq, headers) headers = append(headers, seenHdr)
case 2: // Пишем новый файл в Submitted/
cgp.Discard(seq, qid, from, rcpts) if err := msg.RewriteSubject(headers, res.Subject); err != nil {
} cgp.Failure(seq, msg.QID, fmt.Errorf("RewriteSubject failed: %v", err))
} return
} }
func procActionRS(seq int, qid int, opsum *config.Operation, res map[string]interface{}, // Удаляем оригинал.
headers []string, hci string, subject string, from string, rcpts []string, body []byte, // Если в opsum был MirrorTo, он уйдет в этой же строке (SEQ [MIRRORTO] DISCARD).
notifyfrom string, desc string, outbound bool) { var actualMirror []string
err := cgp.RewriteSubject(seq, headers, subject, qid, from, rcpts, body)
if err != nil {
cgp.Failure(seq, qid, err)
} else {
if opsum != nil { if opsum != nil {
cgp.AddHeaderWithMirrorTo(seq, qid, opsum.MirrorTo, opsum.Discard, headers, body, outbound) actualMirror = opsum.MirrorTo
if len(opsum.NotifyTo) > 0 || opsum.NotifyRcpts { }
if opsum.NotifyRcpts {
to := make([]string, 0, len(opsum.NotifyTo)+len(rcpts)) // Финальный ответ
to = append(to, opsum.NotifyTo...) cgp.MirrorTo(seq, msg, actualMirror, headers, true)
rcpts_filtered := filterLocalRcpts(rcpts, res) }
to = append(to, rcpts_filtered...)
to = utils.UniqueSliceElementsNonEmpty(to) func procActionSwitch(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, headers []string, hci, action, desc string) {
cgp.NotifyTo(seq, qid, to, hci, from, rcpts, body, notifyfrom, desc) 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 { } else {
cgp.NotifyTo(seq, qid, opsum.NotifyTo, hci, from, rcpts, body, notifyfrom, desc) 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))
} }
} }
} else { func procNotifications(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, hci, notifyfrom, desc string) {
cgp.Discard(seq, qid, from, rcpts) 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)
}
})
}
}
+97 -103
View File
@@ -4,160 +4,154 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io"
"net/http" "net/http"
"strconv" "strconv"
"sync"
"git.vsu.ru/ai/rspamd-cgp/cgp" "git.vsu.ru/ai/rspamd-cgp/cgp"
"git.vsu.ru/ai/rspamd-cgp/config" "git.vsu.ru/ai/rspamd-cgp/config"
) )
const ( type Duration float64
headerCase string = "X-Rspamd-Case: "
headerJunkG string = "X-Junk-Score: [XX]" func (d Duration) Append(dst []byte) []byte {
headerJunkA string = "X-Junk-Score: [XXXX]" return strconv.AppendFloat(dst, float64(d), 'f', 6, 64)
headerJunkR string = "X-Junk-Score: [XXXXXXXXXX]" }
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
) )
func Scan(conf *config.Config, seq int, filename string) { func Scan(seq int, filename string) {
tr := &http.Transport{ clientOne.Do(func() {
client = &http.Client{
Timeout: config.Timeout(),
Transport: &http.Transport{
DisableCompression: true, DisableCompression: true,
},
} }
})
client := &http.Client{ msg, err := cgp.NewMessage(seq, filename)
Timeout: conf.Timeout,
Transport: tr,
}
from, rcpts, auth, ip, helo, hostname, qid, body, seen, err := cgp.Message(filename)
if err != nil { 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 return
} }
if seen { content := io.NewSectionReader(msg.File, msg.HdrPos, msg.Size-msg.HdrPos)
cgp.OkSeen(seq, qid) req, err := http.NewRequest("POST", config.Host(), content)
return
}
req, err := http.NewRequest("POST", conf.Host, bytes.NewReader(body))
if err != nil { if err != nil {
cgp.Failure(seq, qid, err) cgp.Failure(seq, msg.QID, err)
return return
} }
req.Header.Add("MTA-Tag", conf.AuthservId) req.Header.Add("MTA-Tag", config.AuthservId())
req.Header.Add("User-Agent", "rspamd-cgp") req.Header.Add("User-Agent", "rspamd-cgp")
req.Header.Add("From", from) req.Header.Add("From", msg.From)
req.Header.Add("Queue-ID", strconv.Itoa(qid)) req.Header.Add("Queue-ID", strconv.Itoa(msg.QID))
if len(auth) > 0 { if len(msg.Auth) > 0 {
req.Header.Add("User", auth) req.Header.Add("User", msg.Auth)
} }
if len(ip) > 0 { if len(msg.IP) > 0 {
req.Header.Add("IP", ip) req.Header.Add("IP", msg.IP)
} }
if len(helo) > 0 { if len(msg.Helo) > 0 {
req.Header.Add("Helo", helo) req.Header.Add("Helo", msg.Helo)
} }
if len(hostname) > 0 { if len(msg.Hostname) > 0 {
req.Header.Add("Hostname", hostname) req.Header.Add("Hostname", msg.Hostname)
} }
for _, rcpt := range rcpts { for _, rcpt := range msg.Rcpts {
req.Header.Add("Rcpt", rcpt) req.Header.Add("Rcpt", rcpt)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
cgp.Failure(seq, qid, err) cgp.Failure(seq, msg.QID, err)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
rbody, err := ioutil.ReadAll(resp.Body) var res RspamdResponse
if err != nil { var reader io.Reader = resp.Body
cgp.Failure(seq, qid, err) 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 return
} }
var res map[string]interface{} if config.Debug() {
if err := json.Unmarshal(rbody, &res); err != nil { msg.PrintMsgInfo()
cgp.Failure(seq, qid, err) printResponse(debugBuf)
}
action := res.Action
if action == "" {
cgp.Failure(seq, msg.QID, fmt.Errorf("missing or invalid 'action' field"))
return return
} }
if conf.Debug { opsum, caseinfo, casework, desc := makeOpSum(&res, action)
printMsgInfo(from, rcpts, auth, ip, helo, hostname, qid, seen)
printResponse(res)
}
action := res["action"].(string) var headers []string
opsum, caseinfo, casework, desc := makeOpSum(conf, res, action) if config.Outbound() {
headers = makeHeadersOutbound(&res)
headers := make([]string, 0, 8)
if conf.Outbound {
headers = makeHeadersOutbound(res)
} else { } else {
headers = makeHeaders(res) headers = makeHeaders(&res)
} }
cgp.Putline("* %d [%d]: Action: %s; Score: %.2f/%.2f; Time elapsed: %.3fs\n", cgp.Putline("* ", seq, " [", msg.QID, "]: Action: ", action,
seq, qid, action, res["score"], res["required_score"], res["time_real"]) "; Score: ", res.Score, "/", res.RequiredScore, "; Time elapsed: ", res.TimeReal)
var hci string var hci string
if opsum != nil { if opsum != nil {
cgp.Putline("* %d [%d]: Case: %s; %s\n", seq, qid, caseinfo, casework) cgp.Putline("* ", seq, " [", msg.QID, "]: Case: ", caseinfo, "; ", casework)
hci = headerCase + caseinfo hci = headerCase + caseinfo
if !conf.Outbound { if !config.Outbound() {
headers = append(headers, hci) headers = append(headers, hci)
} }
} }
switch action { procActionSwitch(seq, msg, opsum, &res, headers, hci, action, desc)
case "no action":
procAction(seq, qid, opsum, res, headers, hci, from, rcpts, body, conf.NotifyFrom, desc, conf.Outbound, 0)
case "discard":
if !conf.Outbound {
headers = append(headers, headerJunkR)
}
procAction(seq, qid, opsum, res, headers, hci, from, rcpts, body, conf.NotifyFrom, desc, conf.Outbound, 2)
case "quarantine":
fallthrough
case "reject":
if !conf.Outbound {
headers = append(headers, headerJunkR)
}
procAction(seq, qid, opsum, res, headers, hci, from, rcpts, body, conf.NotifyFrom, desc, conf.Outbound, 1)
case "rewrite subject":
if subject, ok := res["subject"]; ok {
procActionRS(seq, qid, opsum, res, headers, hci, subject.(string), from, rcpts, body, conf.NotifyFrom, desc, conf.Outbound)
} else {
if !conf.Outbound {
headers = append(headers, headerJunkA)
}
procAction(seq, qid, opsum, res, headers, hci, from, rcpts, body, conf.NotifyFrom, desc, conf.Outbound, 1)
}
case "add header":
if !conf.Outbound {
headers = append(headers, headerJunkA)
}
procAction(seq, qid, opsum, res, headers, hci, from, rcpts, body, conf.NotifyFrom, desc, conf.Outbound, 1)
case "greylist":
fallthrough
case "soft reject":
if !conf.Outbound {
headers = append(headers, headerJunkG)
}
procAction(seq, qid, opsum, res, headers, hci, from, rcpts, body, conf.NotifyFrom, desc, conf.Outbound, 1)
default:
cgp.Failure(seq, qid, fmt.Errorf("Unknown action: %v", action))
}
} }
+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")
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
# Устанавливает значение authserv-id в заголовке Authentication-Results, RFC7001 # Устанавливает значение authserv-id в заголовке Authentication-Results, RFC7001
# Если не задан, ему присваивается имя главного домена CommuniGate Pro. # Если не задан, ему присваивается имя главного домена CommuniGate Pro.
# #
#authservid: mx.domain.ru authservid: mx.domain.name
################################################################################ ################################################################################
# Адрес хоста и порт Rspamd, тайм-аут. # Адрес хоста и порт Rspamd, тайм-аут.
@@ -17,7 +17,7 @@ timeout: 15s
################################################################################ ################################################################################
# Устанавливает значение заголовка From: в оповещениях. # Устанавливает значение заголовка From: в оповещениях.
# #
notifyfrom: rspamd-cgp-notify@domain.ru notifyfrom: rspamd-cgp-notify@domain.name
################################################################################ ################################################################################
# Секция описывает действия (actions) и дополнительную обработку для каждого # Секция описывает действия (actions) и дополнительную обработку для каждого
+5 -30
View File
@@ -1,39 +1,14 @@
package utils package utils
import ( import (
"strings" "unsafe"
"unicode"
) )
func DiffSlice[T comparable](s1, s2 []T) []T { func Bytes2string(b []byte) string {
if len(b) == 0 {
s2m := make(map[T]bool, len(s2)) return ""
for _, v := range s2 {
s2m[v] = true
} }
return unsafe.String(unsafe.SliceData(b), len(b))
var diff []T
for _, v := range s1 {
if _, found := s2m[v]; !found {
diff = append(diff, v)
}
}
return diff
}
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 UniqueSliceElementsNonEmpty[T ~string](s []T) []T { func UniqueSliceElementsNonEmpty[T ~string](s []T) []T {
+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])
}
}
})
}
}