Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 185637b990 | |||
| 7ba94253e3 | |||
| be1849681d | |||
| ee953c1b60 | |||
| d5b7405971 | |||
| 0cad04da66 | |||
| 7157abd56c | |||
| f00cfd4153 | |||
| b823f4f0a3 | |||
| 02d6e8fa06 | |||
| 6c86bbf6b0 | |||
| 1d9e6b5b33 | |||
| 9401266553 | |||
| 3c5aef25e9 | |||
| ebb759cb30 | |||
| fd76bb19f8 | |||
| 8127c512ce | |||
| 8373f81f24 | |||
| fcaa4b1a9d | |||
| 53ec580c99 | |||
| 6f36f283e2 | |||
| ccb1ccc20e | |||
| ac6fdff1dd | |||
| 0816768341 | |||
| 2add0e8caa | |||
| a83623e6da | |||
| 4cf590ef5f | |||
| 37fa8a3d09 | |||
| 6686a722b8 | |||
| 9ea6e3a535 | |||
| 938ff7d8c9 | |||
| d3c34f4414 | |||
| f5d60164be | |||
| ca99deb5e1 | |||
| b074a371f5 | |||
| 40ba8f6878 | |||
| 2b8d64c89e | |||
| 659d1bf531 | |||
| 9a4226ceea | |||
| aed98832e4 | |||
| ae3586e0cc | |||
| 3ef6db6a7d | |||
| c5a5acbd4f | |||
| 489783d652 | |||
| 7462a3de80 |
+23
-22
@@ -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
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
3. The name of the author may not be used to endorse or promote
|
||||
products derived from this software without specific prior written
|
||||
permission.
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
|
||||
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
|
||||
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,58 +1,60 @@
|
||||
[](#) [](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
|
||||

|
||||
|
||||
##### Установки -> Почта -> Правила -> RSPAMD_in
|
||||
##### Settings -> Mail -> Rules -> RSPAMD_in
|
||||

|
||||
|
||||
#### Для исходящих сообщений
|
||||
#### For Outbound Messages
|
||||
|
||||
##### Установки -> Общее -> Помощники
|
||||
##### Settings -> General -> Helpers
|
||||

|
||||
|
||||
##### Установки -> Почта -> Правила -> RSPAMD_out
|
||||
##### Settings -> Mail -> Rules -> RSPAMD_out
|
||||

|
||||
|
||||
#### Параметры командной строки
|
||||
#### Command Line Arguments
|
||||
|
||||
```
|
||||
Usage of rspamd-cgp:
|
||||
@@ -66,44 +68,44 @@
|
||||
Run in debug mode
|
||||
-outbound
|
||||
Outbound message flow processing
|
||||
-version
|
||||
Print version and exit
|
||||
```
|
||||
|
||||
**config**
|
||||
|
||||
Указывает альтернативный конфигурационный файл.
|
||||
Specifies an alternative configuration file.
|
||||
|
||||
**configdump**
|
||||
|
||||
Выводит конфигурационный файл в форматированном виде.
|
||||
Outputs the configuration file in a formatted view.
|
||||
|
||||
**configtest**
|
||||
|
||||
Проверяет синтаксическую корректность конфигурационного файла.
|
||||
Verifies the syntactic correctness of the configuration file.
|
||||
|
||||
**debug**
|
||||
|
||||
Выводит в форматированном виде ответ *Rspamd* (JSON). Может быть
|
||||
использован для контроля возвращаемых *Rspamd* *символов* и других данных. Входной файл должен быть в формате файла очереди *CommuniGate Pro*. ***Применять только при запуске из командной строки!!!***
|
||||
Outputs the *Rspamd* response (JSON) in a formatted view. Can be used to monitor *symbols* and other data returned by *Rspamd*. The input file must be in the *CommuniGate Pro* queue file format. ***Use only when running from the command line!!!***
|
||||
|
||||
**outbound**
|
||||
|
||||
Обрабатывает поток исходящих сообщений. Если исходящие сообщения отправляются на внешние MTA, в них не добавляются заголовки, являющиеся результатом проверки на спам. Какие именно сообщения обрабатываются таким образом определяется *Правилом*
|
||||
*CommuniGate Pro*.
|
||||
Processes the outbound message flow. If outbound messages are sent to external MTAs, spam check headers are not added. The specific messages to be processed in this mode are determined by the *CommuniGate Pro Rule*.
|
||||
|
||||
<br/>
|
||||
|
||||
### Лицензия
|
||||
### License
|
||||
|
||||
BSD License, [LICENSE.md](LICENSE.md)<br/><br/>
|
||||
|
||||
### Автор
|
||||
### Author
|
||||
|
||||
Andrey Igoshin <<ai@vsu.ru>><br/><br/>
|
||||
|
||||
### Ссылки
|
||||
### Links
|
||||
|
||||
* Репозиторий: <https://git.vsu.ru/ai/rspamd-cgp>
|
||||
* Сайт CommuniGate Pro: <https://communigatepro.ru>
|
||||
* Протокол Помощника: <https://old.communigatepro.ru/CommuniGatePro/russian/Helpers.html#Filters>
|
||||
* Сайт Rspamd: <https://rspamd.com>
|
||||
* Протокол Rspamd: <https://rspamd.com/doc/developers/protocol.html>
|
||||
* Repository: <https://git.vsu.ru/ai/rspamd-cgp>
|
||||
* CommuniGate Pro Website: <https://communigatepro.ru>
|
||||
* Helper Protocol: <https://doc.communigatepro.ru/russian/development/Helpers.html#Filters>
|
||||
* Rspamd Website: <https://rspamd.com>
|
||||
* Rspamd Protocol: <https://rspamd.com/doc/developers/protocol.html>
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
[](README.md) [](#)
|
||||
|
||||
## 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*.
|
||||
|
||||
#### Для входящих сообщений
|
||||
|
||||
##### Установки -> Общее -> Помощники
|
||||

|
||||
|
||||
##### Установки -> Почта -> Правила -> RSPAMD_in
|
||||

|
||||
|
||||
#### Для исходящих сообщений
|
||||
|
||||
##### Установки -> Общее -> Помощники
|
||||

|
||||
|
||||
##### Установки -> Почта -> Правила -> RSPAMD_out
|
||||

|
||||
|
||||
#### Параметры командной строки
|
||||
|
||||
```
|
||||
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>
|
||||
@@ -2,23 +2,50 @@
|
||||
|
||||
export GOPATH="${HOME}/src/rspamd-cgp"
|
||||
|
||||
# если на целевой ОС не совпадает glibc, то собираем без зависимостей.
|
||||
# результирующий файл, возможно, получится медленнее и большего размера.
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "manual")
|
||||
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# Путь к пакету, где лежат переменные
|
||||
PKG="git.vsu.ru/ai/rspamd-cgp/config"
|
||||
|
||||
LDFLAGS="-X '$PKG.Version=$VERSION' -X '$PKG.Commit=$COMMIT' -X '$PKG.BuildTime=$BUILD_TIME'"
|
||||
|
||||
export CGO_ENABLED=0
|
||||
|
||||
if [ "$1" == "fmt" ]; then
|
||||
go fmt $2
|
||||
elif [ "$1" == "get" ]; then
|
||||
go get $2
|
||||
elif [ "$1" == "tidy" ]; then
|
||||
go mod tidy
|
||||
elif [ "$1" == "update" ]; then
|
||||
echo "update..."
|
||||
go get -u ./...
|
||||
go mod tidy
|
||||
elif [ "$1" == "vet" ]; then
|
||||
echo "vet..."
|
||||
go vet ./...
|
||||
else
|
||||
go build
|
||||
fi
|
||||
case "$1" in
|
||||
"fmt")
|
||||
go fmt ./...
|
||||
;;
|
||||
"fix")
|
||||
go fix -diff ./...
|
||||
;;
|
||||
"test")
|
||||
go test -v ./...
|
||||
;;
|
||||
"test-one")
|
||||
go test -v -run "$2"
|
||||
;;
|
||||
"bench")
|
||||
go test -bench=. -benchmem -run=^#
|
||||
;;
|
||||
"pprof")
|
||||
go test -cpu=1 -bench=BenchmarkRspamc_Scan_RealWork -benchmem -memprofile mem.out -cpuprofile cpu.out
|
||||
;;
|
||||
"tidy")
|
||||
go mod tidy
|
||||
;;
|
||||
"update")
|
||||
echo "Updating dependencies..."
|
||||
go get -u ./...
|
||||
go mod tidy
|
||||
;;
|
||||
"vet")
|
||||
echo "Running static analysis..."
|
||||
go vet ./...
|
||||
;;
|
||||
*)
|
||||
echo "Building version $VERSION..."
|
||||
go build -ldflags="$LDFLAGS" -o rspamd-cgp
|
||||
;;
|
||||
esac
|
||||
|
||||
+244
-289
@@ -3,350 +3,305 @@ package cgp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/utils"
|
||||
)
|
||||
|
||||
const recvHdr = "Received:"
|
||||
const seenHdr = "X-Rspamd-Seen:"
|
||||
const subjHdr = "Subject:"
|
||||
const submitDir = "Submitted"
|
||||
type Appender interface {
|
||||
Append([]byte) []byte
|
||||
}
|
||||
|
||||
var reHELO1 *regexp.Regexp
|
||||
var reHELO2 *regexp.Regexp
|
||||
var reMD *regexp.Regexp
|
||||
var reSELF *regexp.Regexp
|
||||
var reSMTP *regexp.Regexp
|
||||
var protocol int
|
||||
const (
|
||||
submitDir = "Submitted"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Received: from muus52.sndsy.ru ([185.235.30.52] verified)
|
||||
reHELO1 = regexp.MustCompile(`^Received: from (\S+) \(.* ?\[\S+\] verified\)`)
|
||||
// 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+([^;]+);`)
|
||||
reSELF = regexp.MustCompile(`^S (?:<([^>]+)> )?(ALARM|DSN|GROUP|ICAL|LIST|LSTM|LSTO|MDN|PBX|PIPE|RULE|WEBUSER) \[0\.0\.0\.0\]`)
|
||||
reSMTP = regexp.MustCompile(`^S (?:<([^>]+)> )?(?:SMTP|HTTPU?|AIRSYNC|IMAP|RPOP|XIMSS) \[([0-9a-f.:]+)\]`)
|
||||
var (
|
||||
protocol int = 4
|
||||
stdoutFd int
|
||||
)
|
||||
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() any { return bytes.NewBuffer(make([]byte, 0, 4096)) },
|
||||
}
|
||||
|
||||
func AddHeader(seq int, headers []string) {
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
defer putBuffer(buf)
|
||||
|
||||
hdrs := replaceSpecChars(strings.Join(headers, "\n"))
|
||||
buf.WriteString(strconv.Itoa(seq))
|
||||
buf.WriteString(" ADDHEADER \"")
|
||||
|
||||
if protocol >= 4 {
|
||||
Putline("%d ADDHEADER \"%s\" OK\n", seq, hdrs)
|
||||
} else {
|
||||
Putline("%d ADDHEADER \"%s\"\n", seq, hdrs)
|
||||
for i, h := range headers {
|
||||
replaceSpecCharsBuf(buf, h)
|
||||
if i < len(headers)-1 {
|
||||
buf.WriteString("\\e")
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString("\" OK")
|
||||
|
||||
res := buf.Bytes()
|
||||
length := len(res)
|
||||
|
||||
if length > 4096 || (length == 4096 && res[length-1] != '\n') {
|
||||
Failure(seq, 0, fmt.Errorf("AddHeader: result exceeds 4k limit"))
|
||||
return
|
||||
}
|
||||
|
||||
if length > 0 && res[length-1] != '\n' {
|
||||
buf.WriteByte('\n')
|
||||
res = buf.Bytes()
|
||||
}
|
||||
|
||||
syscall.Write(stdoutFd, res)
|
||||
}
|
||||
|
||||
func AddHeaderWithMirrorTo(seq int, qid int, to []string, discard bool, headers []string,
|
||||
body []byte, outbound bool) {
|
||||
|
||||
if !outbound || len(to) > 0 {
|
||||
seenHdr, err := makeSeen(body)
|
||||
if err != nil {
|
||||
Failure(seq, qid, err)
|
||||
return
|
||||
}
|
||||
headers = append(headers, seenHdr)
|
||||
}
|
||||
|
||||
hdrs := replaceSpecChars(strings.Join(headers, "\n"))
|
||||
|
||||
if protocol >= 4 {
|
||||
|
||||
if len(to) > 0 {
|
||||
mirrorTo := make([]string, 0, len(to))
|
||||
for _, m := range to {
|
||||
mirrorTo = append(mirrorTo, "MIRRORTO \""+m+"\"")
|
||||
}
|
||||
|
||||
if discard {
|
||||
Putline("%d ADDHEADER \"%s\" %s DISCARD\n", seq, hdrs, strings.Join(mirrorTo, " "))
|
||||
} else {
|
||||
Putline("%d ADDHEADER \"%s\" %s OK\n", seq, hdrs, strings.Join(mirrorTo, " "))
|
||||
}
|
||||
|
||||
} else {
|
||||
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 Discard(seq int) {
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " DISCARD\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
func Failure(seq, qid int, err error) {
|
||||
Putline("* %d [%d]: %s\n", seq, qid, err)
|
||||
Putline("%d FAILURE\n", seq)
|
||||
var buf [512]byte
|
||||
b := buf[:0]
|
||||
|
||||
b = append(b, "* "...)
|
||||
b = strconv.AppendInt(b, int64(seq), 10)
|
||||
b = append(b, " ["...)
|
||||
b = strconv.AppendInt(b, int64(qid), 10)
|
||||
b = append(b, "]: "...)
|
||||
if err != nil {
|
||||
b = append(b, err.Error()...)
|
||||
} else {
|
||||
b = append(b, "unknown error"...)
|
||||
}
|
||||
|
||||
length := len(b)
|
||||
if length > 0 && b[length-1] != '\n' {
|
||||
b = append(b, '\n')
|
||||
}
|
||||
syscall.Write(stdoutFd, b)
|
||||
|
||||
b = buf[:0]
|
||||
b = strconv.AppendInt(b, int64(seq), 10)
|
||||
b = append(b, " FAILURE\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
// InitStdoutFd инициализирует системный дескриптор для ответов серверу.
|
||||
// Вызывать в самом начале main().
|
||||
func InitStdoutFd() error {
|
||||
rawConn, err := os.Stdout.SyscallConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = rawConn.Control(func(fd uintptr) {
|
||||
stdoutFd = int(fd)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Intf(seq int, ver string) {
|
||||
protocol, _ = strconv.Atoi(ver)
|
||||
Putline("%d INTF %d\n", seq, protocol)
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " INTF "...)
|
||||
b = append(b, strconv.FormatInt(int64(protocol), 10)...)
|
||||
b = append(b, '\n')
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
func MainDomain() (domain string, err error) {
|
||||
|
||||
func MainDomain() (string, error) {
|
||||
h, err := os.Open("Settings/Main.settings")
|
||||
if err != nil {
|
||||
return
|
||||
return "", err
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
var line []byte
|
||||
|
||||
for m := bufio.NewReader(h); ; {
|
||||
|
||||
line, err = m.ReadSlice('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s := reMD.FindAllStringSubmatch(string(line), -1); s != nil {
|
||||
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)
|
||||
rd := bufio.NewReader(h)
|
||||
key := []byte("DomainName")
|
||||
|
||||
for {
|
||||
|
||||
line, err = m.ReadSlice('\n')
|
||||
if err != nil {
|
||||
return
|
||||
line, err := rd.ReadSlice('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pos += int64(len(line))
|
||||
|
||||
if string(line) == "\n" {
|
||||
break
|
||||
// Ищем вхождение DomainName
|
||||
idxKey := bytes.Index(line, key)
|
||||
if idxKey == -1 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch line[0] {
|
||||
case 'P':
|
||||
s := strings.IndexByte(string(line), '<')
|
||||
from = string(line[s : s+strings.IndexByte(string(line[s:]), '>')+1])
|
||||
|
||||
case 'R':
|
||||
s := strings.IndexByte(string(line), '<')
|
||||
rcpts = append(rcpts, string(line[s:s+strings.IndexByte(string(line[s:]), '>')+1]))
|
||||
|
||||
case 'S':
|
||||
if s := reSMTP.FindAllStringSubmatch(string(line), -1); s != nil {
|
||||
auth = s[0][1]
|
||||
ip = s[0][2]
|
||||
hostname = getHostname(ip)
|
||||
} else if s := reSELF.FindAllStringSubmatch(string(line), -1); s != nil {
|
||||
if len(s[0][1]) > 0 {
|
||||
auth = s[0][1]
|
||||
} else {
|
||||
auth = s[0][2] + "@trusted"
|
||||
// Проверяем, что перед ключом стоит разделитель (начало строки, пробел, { или ;)
|
||||
if idxKey > 0 {
|
||||
prev := line[idxKey-1]
|
||||
if prev != ' ' && prev != '\t' && prev != '{' && prev != ';' {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
ip = "127.2.4.7"
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Ищем '=' после ключа
|
||||
lineAfterKey := line[idxKey+len(key):]
|
||||
idxEq := bytes.IndexByte(lineAfterKey, '=')
|
||||
if idxEq == -1 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Ищем ';' после '='
|
||||
idxSemi := bytes.IndexByte(lineAfterKey[idxEq:], ';')
|
||||
if idxSemi == -1 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Извлекаем и чистим значение
|
||||
rawVal := lineAfterKey[idxEq+1 : idxEq+idxSemi]
|
||||
cleanVal := bytes.Trim(rawVal, " \t\r\n\"")
|
||||
|
||||
if len(cleanVal) > 0 {
|
||||
return string(cleanVal), nil
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
seen, err = isSeen(m)
|
||||
if err != nil {
|
||||
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 {
|
||||
buf.WriteString(" OK")
|
||||
}
|
||||
|
||||
res := buf.Bytes()
|
||||
if len(res) > 4096 {
|
||||
Failure(seq, m.QID, fmt.Errorf("MirrorTo: result exceeds 4k limit (%d bytes)", len(res)))
|
||||
return
|
||||
}
|
||||
|
||||
if seen {
|
||||
return
|
||||
if len(res) > 0 && res[len(res)-1] != '\n' {
|
||||
buf.WriteByte('\n')
|
||||
res = buf.Bytes()
|
||||
}
|
||||
|
||||
fi, err := h.Stat()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.Seek(pos, os.SEEK_SET)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body = make([]byte, fi.Size()-pos)
|
||||
n, err := h.Read(body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
syscall.Write(stdoutFd, res)
|
||||
}
|
||||
|
||||
func Ok(seq int) {
|
||||
Putline("%d OK\n", seq)
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " OK\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
func OkSeen(seq, qid int) {
|
||||
Putline("* %d [%d]: Already seen by Rspamd\n", seq, qid)
|
||||
Putline("%d OK\n", seq)
|
||||
var buf [128]byte
|
||||
b := buf[:0]
|
||||
b = append(b, "* "...)
|
||||
b = strconv.AppendInt(b, int64(seq), 10)
|
||||
b = append(b, " ["...)
|
||||
b = strconv.AppendInt(b, int64(qid), 10)
|
||||
b = append(b, "]: Already seen by Rspamd\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
func Putline(format string, a ...interface{}) {
|
||||
s := fmt.Sprintf(format, a...)
|
||||
syscall.Write(int(os.Stdout.Fd()), []byte(s))
|
||||
func Putline(a ...any) {
|
||||
var stackBuf [256]byte
|
||||
res := stackBuf[:0]
|
||||
|
||||
for _, arg := range a {
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
res = append(res, v...)
|
||||
case int:
|
||||
res = strconv.AppendInt(res, int64(v), 10)
|
||||
case int64:
|
||||
res = strconv.AppendInt(res, v, 10)
|
||||
case float64:
|
||||
res = strconv.AppendFloat(res, v, 'f', 2, 64)
|
||||
case Appender:
|
||||
res = v.Append(res)
|
||||
case float32:
|
||||
res = strconv.AppendFloat(res, float64(v), 'f', 2, 64)
|
||||
case error:
|
||||
if v != nil {
|
||||
res = append(res, v.Error()...)
|
||||
}
|
||||
case []byte:
|
||||
res = append(res, v...)
|
||||
default:
|
||||
res = append(res, fmt.Sprint(v)...)
|
||||
}
|
||||
}
|
||||
|
||||
length := len(res)
|
||||
|
||||
if length > 4096 || (length == 4096 && length > 0 && res[length-1] != '\n') {
|
||||
os.Stderr.WriteString("Putline: result exceeds 4k limit\n")
|
||||
return
|
||||
}
|
||||
|
||||
if length > 0 && res[length-1] != '\n' {
|
||||
res = append(res, '\n')
|
||||
}
|
||||
|
||||
syscall.Write(stdoutFd, res)
|
||||
}
|
||||
|
||||
func Reject(seq int) {
|
||||
Putline("%d REJECT Try again later\n", seq)
|
||||
}
|
||||
|
||||
func RewriteSubject(seq int, headers []string, subject string, qid int, from string, rcpts []string, body []byte) (err error) {
|
||||
|
||||
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
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " REJECT Try again later\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
+141
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+78
-33
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -92,70 +91,116 @@ var getHostname = func() func(addr string) (hostname string) {
|
||||
}
|
||||
}()
|
||||
|
||||
var IsValidDomain = func() func(domain string) bool {
|
||||
rx := regexp.MustCompile(`^(?i)[a-z0-9-]+(\.[a-z0-9-]+)+\.?$`)
|
||||
return func(domain string) bool {
|
||||
return rx.MatchString(domain)
|
||||
func IsValidDomain(domain string) bool {
|
||||
if len(domain) == 0 || len(domain) > 253 {
|
||||
return false
|
||||
}
|
||||
}()
|
||||
|
||||
// Fast path: IP-адреса (v4 и v6) обычно начинаются с цифры или двоеточия.
|
||||
// Домены по RFC могут начинаться с цифры, но это редкость.
|
||||
// Если первый символ - цифра или ':', проверяем, не IP ли это.
|
||||
first := domain[0]
|
||||
if (first >= '0' && first <= '9') || first == ':' {
|
||||
if _, err := netip.ParseAddr(strings.TrimSuffix(domain, ".")); err == nil {
|
||||
return false // Это чистый IP
|
||||
}
|
||||
}
|
||||
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
lastDot := -1
|
||||
hasDot := false
|
||||
|
||||
for i := 0; i < len(domain); i++ {
|
||||
c := domain[i]
|
||||
if c == '.' {
|
||||
labelLen := i - lastDot - 1
|
||||
// Пустые метки (..) или дефис по краям метки недопустимы
|
||||
if labelLen == 0 || labelLen > 63 || domain[lastDot+1] == '-' || domain[i-1] == '-' {
|
||||
return false
|
||||
}
|
||||
lastDot = i
|
||||
hasDot = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Допустимые символы: [a-zA-Z0-9_-]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка последней метки (TLD)
|
||||
finalLabelLen := len(domain) - lastDot - 1
|
||||
if finalLabelLen == 0 || finalLabelLen > 63 || domain[lastDot+1] == '-' || domain[len(domain)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
return hasDot
|
||||
}
|
||||
|
||||
func lookupAddr(ipAddr netip.Addr) (hostname string) {
|
||||
|
||||
r := &net.Resolver{PreferGo: true}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
names, err := r.LookupAddr(ctx, ipAddr.String())
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(names) == 1 {
|
||||
hostname = names[0]
|
||||
} else if len(names) > 1 {
|
||||
hostname = strings.TrimSuffix(names[0], ".")
|
||||
return
|
||||
}
|
||||
|
||||
found := make([]string, 0, len(names))
|
||||
type result struct{ name string }
|
||||
ch := make(chan result, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
for _, name := range names {
|
||||
go func(name string) {
|
||||
ips, err := r.LookupHost(ctx, name)
|
||||
cancel()
|
||||
if err != nil {
|
||||
ch <- result{}
|
||||
return
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
ipTest, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ipTest == ipAddr {
|
||||
found = append(found, name)
|
||||
break
|
||||
ch <- result{name}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ch <- result{}
|
||||
}(name)
|
||||
}
|
||||
|
||||
switch len(found) {
|
||||
case 0:
|
||||
// если ни один name не имеет корректного прямого резольвинга
|
||||
// в исходный ip, выбираем самый длинный.
|
||||
hostname = findLongerItem(filterNames(names))
|
||||
|
||||
case 1:
|
||||
hostname = found[0]
|
||||
|
||||
default:
|
||||
// если несколько name имеют корректный прямой резольвинг
|
||||
// в исходный ip, выбираем самый короткий.
|
||||
hostname = findShorterItem(filterNames(found))
|
||||
found := make([]string, 0, len(names))
|
||||
for range names {
|
||||
if r := <-ch; r.name != "" {
|
||||
found = append(found, r.name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(hostname) > 0 {
|
||||
hostname = strings.TrimSuffix(hostname, ".")
|
||||
switch len(found) {
|
||||
case 0:
|
||||
// если ни один name не имеет корректного прямого резольвинга
|
||||
// в исходный ip, выбираем самый длинный.
|
||||
hostname = findLongerItem(filterNames(names))
|
||||
|
||||
case 1:
|
||||
hostname = found[0]
|
||||
|
||||
default:
|
||||
// если несколько name имеют корректный прямой резольвинг
|
||||
// в исходный ip, выбираем самый короткий.
|
||||
hostname = findShorterItem(filterNames(found))
|
||||
}
|
||||
|
||||
hostname = strings.TrimSuffix(hostname, ".")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
+88
-221
@@ -3,246 +3,113 @@ package cgp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/utils"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func getHeader(m *bufio.Reader) (hdr string, err error) {
|
||||
|
||||
var c byte
|
||||
var line []byte
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(384)
|
||||
|
||||
for {
|
||||
c, err = m.ReadByte()
|
||||
if err == io.EOF {
|
||||
if c == 0 {
|
||||
break
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if b.Len() == 0 {
|
||||
|
||||
if c == ' ' || c == '\t' {
|
||||
err = m.UnreadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = fmt.Errorf("bad header")
|
||||
return
|
||||
|
||||
} else if c == '\n' {
|
||||
|
||||
b.WriteByte(c)
|
||||
break
|
||||
|
||||
} else {
|
||||
|
||||
b.WriteByte(c)
|
||||
|
||||
line, err = m.ReadSlice('\n')
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b.Write(line)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if c == ' ' || c == '\t' {
|
||||
|
||||
b.WriteByte(c)
|
||||
|
||||
line, err = m.ReadSlice('\n')
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b.Write(line)
|
||||
|
||||
} else {
|
||||
err = m.UnreadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
func extractAngle(line []byte) (string, bool) {
|
||||
s := strings.IndexByte(string(line), '<')
|
||||
if s < 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
hdr = b.String()
|
||||
|
||||
return
|
||||
e := strings.IndexByte(string(line[s:]), '>')
|
||||
if e < 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(line[s+1 : s+e]), true
|
||||
}
|
||||
|
||||
func getHelo(body []byte) (helo string) {
|
||||
|
||||
var hdr string
|
||||
var err error
|
||||
|
||||
m := bufio.NewReader(bytes.NewReader(body))
|
||||
|
||||
func getHeader(m *bufio.Reader, buf *bytes.Buffer) error {
|
||||
for {
|
||||
|
||||
hdr, err = m.ReadString('\n')
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
if len(hdr) == 0 {
|
||||
break
|
||||
line, err := m.ReadSlice('\n')
|
||||
if err != nil && err != bufio.ErrBufferFull {
|
||||
if len(line) > 0 {
|
||||
buf.Write(line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if hdr == "\n" {
|
||||
// конец RFC5322 заголовка
|
||||
break
|
||||
}
|
||||
buf.Write(line)
|
||||
|
||||
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]
|
||||
if err == bufio.ErrBufferFull {
|
||||
if buf.Len() > 64*1024 {
|
||||
return fmt.Errorf("header too long")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
case rune('\n'):
|
||||
// replace \n -> \\e (CGP End-of-Line)
|
||||
sb.WriteString("\\e")
|
||||
if isHeaderEnd(line) {
|
||||
return nil
|
||||
}
|
||||
|
||||
case rune('\t'):
|
||||
// replace \t -> \\t (CGP Tab)
|
||||
sb.WriteString("\\t")
|
||||
next, err := m.Peek(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case rune('"'):
|
||||
// replace \" -> \\" (CGP quote)
|
||||
sb.WriteString("\\\"")
|
||||
if next[0] != ' ' && next[0] != '\t' {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
sb.WriteRune(symbol)
|
||||
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]))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
// Received: from [77.83.39.182] (HELO poseidonms.com)
|
||||
// Received: from [10.19.5.40] (account test@domain.name HELO test.domain.name)
|
||||
if idxHelo := bytes.Index(data, []byte("HELO ")); idxHelo > 0 {
|
||||
prevChar := data[idxHelo-1]
|
||||
if prevChar == ' ' || prevChar == '(' {
|
||||
remaining := data[idxHelo+5:]
|
||||
if end := bytes.IndexByte(remaining, ')'); end != -1 {
|
||||
return string(bytes.TrimSpace(remaining[:end]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func putBuffer(buf *bytes.Buffer) {
|
||||
if buf.Cap() > 4096 {
|
||||
return
|
||||
}
|
||||
buf.Reset()
|
||||
bufferPool.Put(buf)
|
||||
}
|
||||
|
||||
func replaceSpecCharsBuf(buf *bytes.Buffer, s string) {
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
buf.WriteString("\\\\")
|
||||
case '\n':
|
||||
buf.WriteString("\\e")
|
||||
case '\t':
|
||||
buf.WriteString("\\t")
|
||||
case '"':
|
||||
buf.WriteString("\\\"")
|
||||
case '\r', 0x00:
|
||||
continue
|
||||
default:
|
||||
if r < utf8.RuneSelf {
|
||||
buf.WriteByte(byte(r))
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+95
-76
@@ -1,92 +1,111 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NotifyTo(seq int, qid int, to []string, header string, from string, rcpts []string,
|
||||
body []byte, notifyfrom string, desc string) {
|
||||
|
||||
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")
|
||||
|
||||
func NotifyTo(seq int, m *Message, to []string, header string, notifyfrom string, desc string) {
|
||||
mailid := strconv.Itoa(m.QID)
|
||||
sSeq := strconv.Itoa(seq)
|
||||
filename := submitDir + "/" + mailid + "no.sub"
|
||||
filetemp := strings.Replace(filename, "sub", "tmp", 1)
|
||||
filetemp := submitDir + "/" + mailid + "no.tmp"
|
||||
|
||||
fh, err := os.Create(filetemp)
|
||||
if err != nil {
|
||||
goto fin
|
||||
}
|
||||
defer fh.Close()
|
||||
// Локальная функция-обертка для изоляции ресурсов (File, Writer)
|
||||
err := func() error {
|
||||
fh, err := os.Create(filetemp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
goto fin
|
||||
os.Remove(filetemp)
|
||||
Putline("* ", sSeq, " [", mailid, "]: notify: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = fh.Close(); err != nil {
|
||||
goto fin
|
||||
}
|
||||
|
||||
err = os.Rename(filetemp, filename)
|
||||
|
||||
fin:
|
||||
if err != nil {
|
||||
Putline("* %d [%d]: notify: %s\n", seq, qid, err)
|
||||
if err = os.Rename(filetemp, filename); err != nil {
|
||||
os.Remove(filetemp)
|
||||
Putline("* ", sSeq, " [", mailid, "]: notify rename error: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+90
-41
@@ -12,9 +12,16 @@ import (
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
)
|
||||
|
||||
// Эти значения будут перезаписаны при сборке через -ldflags
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
Description string
|
||||
Direction string
|
||||
Direction Direction
|
||||
Discard bool
|
||||
MirrorTo []string
|
||||
NotifyRcpts bool
|
||||
@@ -22,52 +29,73 @@ type Operation struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AuthservId string
|
||||
Debug bool
|
||||
Outbound bool
|
||||
Host string `default:"localhost:11333"`
|
||||
Timeout time.Duration `default:"15s"`
|
||||
NotifyFrom string `required:"true"`
|
||||
Actions map[string]*Operation
|
||||
Symbols map[string]*Operation
|
||||
AuthservId string
|
||||
Host string `default:"localhost:11333"`
|
||||
Timeout time.Duration `default:"15s"`
|
||||
NotifyFrom string `required:"true"`
|
||||
Actions map[string]*Operation
|
||||
Symbols map[string]*Operation
|
||||
debug bool
|
||||
outbound bool
|
||||
showVersion bool
|
||||
}
|
||||
|
||||
func New() *Config {
|
||||
var configInstance *Config
|
||||
|
||||
config := new(Config)
|
||||
func Action(a string) (op *Operation, ok bool) {
|
||||
op, ok = configInstance.Actions[a]
|
||||
return
|
||||
}
|
||||
|
||||
func AuthservId() string {
|
||||
return configInstance.AuthservId
|
||||
}
|
||||
|
||||
func Debug() bool {
|
||||
return configInstance.debug
|
||||
}
|
||||
|
||||
func GetVersion() string {
|
||||
return fmt.Sprintf("rspamd-cgp\n version: %s\n commit: %s\n built: %s\n",
|
||||
Version, Commit, BuildTime)
|
||||
}
|
||||
|
||||
func Host() string {
|
||||
return configInstance.Host
|
||||
}
|
||||
|
||||
func New(args []string) (*Config, error) {
|
||||
|
||||
c := &Config{}
|
||||
|
||||
fs := flag.NewFlagSet("rspamd-cgp", flag.ContinueOnError)
|
||||
|
||||
dir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||
|
||||
var configfile string
|
||||
var configdump bool
|
||||
var configtest bool
|
||||
var debug bool
|
||||
var outbound bool
|
||||
var err error
|
||||
|
||||
flag.StringVar(&configfile, "config", dir+"/rspamd-cgp.yml", "Set configuration file")
|
||||
flag.BoolVar(&configdump, "configdump", false, "Perform configuration file dump")
|
||||
flag.BoolVar(&configtest, "configtest", false, "Perform configuration file test")
|
||||
flag.BoolVar(&debug, "debug", false, "Run in debug mode")
|
||||
flag.BoolVar(&outbound, "outbound", false, "Outbound message flow processing")
|
||||
flag.Parse()
|
||||
fs.StringVar(&configfile, "config", dir+"/rspamd-cgp.yml", "Set configuration file")
|
||||
fs.BoolVar(&configdump, "configdump", false, "Perform configuration file dump")
|
||||
fs.BoolVar(&configtest, "configtest", false, "Perform configuration file test")
|
||||
fs.BoolVar(&c.debug, "debug", false, "Run in debug mode")
|
||||
fs.BoolVar(&c.outbound, "outbound", false, "Outbound message flow processing")
|
||||
fs.BoolVar(&c.showVersion, "version", false, "print version and exit")
|
||||
|
||||
err := configor.Load(config, configfile)
|
||||
if err = fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = configor.Load(c, configfile)
|
||||
if err != nil {
|
||||
fmt.Println("config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if debug {
|
||||
config.Debug = debug
|
||||
}
|
||||
|
||||
if outbound {
|
||||
config.Outbound = outbound
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if configdump {
|
||||
dumpConfig(config)
|
||||
if err = validateConfig(config); err != nil {
|
||||
dumpConfig(c)
|
||||
if err = validateConfig(c); err != nil {
|
||||
fmt.Println("config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -75,7 +103,7 @@ func New() *Config {
|
||||
}
|
||||
|
||||
if configtest {
|
||||
if err = validateConfig(config); err != nil {
|
||||
if err = validateConfig(c); err != nil {
|
||||
fmt.Println("config:", err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
@@ -84,16 +112,37 @@ func New() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
if config.AuthservId == "" {
|
||||
if config.AuthservId, err = cgp.MainDomain(); err != nil {
|
||||
fmt.Println("Can not detect Main Domain:", err)
|
||||
os.Exit(1)
|
||||
if c.AuthservId == "" {
|
||||
if c.AuthservId, err = cgp.MainDomain(); err != nil {
|
||||
return nil, fmt.Errorf("can not detect Main Domain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
setOpDefaultDirection(config)
|
||||
c.Host = "http://" + c.Host + "/checkv2"
|
||||
|
||||
config.Host = "http://" + config.Host + "/checkv2"
|
||||
|
||||
return config
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func NotifyFrom() string {
|
||||
return configInstance.NotifyFrom
|
||||
}
|
||||
|
||||
func Outbound() bool {
|
||||
return configInstance.outbound
|
||||
}
|
||||
|
||||
func SetGlobal(c *Config) {
|
||||
configInstance = c
|
||||
}
|
||||
|
||||
func ShowVersion() bool {
|
||||
return configInstance.showVersion
|
||||
}
|
||||
|
||||
func Symbols() map[string]*Operation {
|
||||
return configInstance.Symbols
|
||||
}
|
||||
|
||||
func Timeout() time.Duration {
|
||||
return configInstance.Timeout
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -9,42 +9,31 @@ import (
|
||||
|
||||
var reMail *regexp.Regexp
|
||||
|
||||
func dumpConfig(config *Config) {
|
||||
yml, err := yaml.Marshal(config)
|
||||
func dumpConfig(c *Config) {
|
||||
yml, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
fmt.Println("config:", err)
|
||||
} 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) {
|
||||
|
||||
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) {
|
||||
func validateConfig(c *Config) (err error) {
|
||||
|
||||
reMail = regexp.MustCompile(`^\S+?@\S+$`)
|
||||
|
||||
if err = validateConfigOp(config.NotifyFrom); err != nil {
|
||||
if err = validateConfigOp(c.NotifyFrom); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = validateConfigEntry(config.Actions); err != nil {
|
||||
if err = validateConfigEntry(c.Actions); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = validateConfigEntry(config.Symbols); err != nil {
|
||||
if err = validateConfigEntry(c.Symbols); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,9 +44,9 @@ func validateConfigEntry(entry map[string]*Operation) (err error) {
|
||||
|
||||
for e, op := range entry {
|
||||
|
||||
if err = validateDirection(op.Direction); err != nil {
|
||||
err = fmt.Errorf("%s: Direction: %v", e, err)
|
||||
break
|
||||
// Проверка диапазона Direction
|
||||
if op.Direction < DirBoth || op.Direction > DirOut {
|
||||
return fmt.Errorf("%s: invalid direction index: %d", e, op.Direction)
|
||||
}
|
||||
|
||||
if err = validateConfigOps(op.NotifyTo); err != nil {
|
||||
@@ -87,19 +76,9 @@ func validateConfigOps(mail []string) (err error) {
|
||||
|
||||
for _, m := range mail {
|
||||
if err = validateConfigOp(m); err != nil {
|
||||
err = fmt.Errorf("invalid mail: %s", m)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func validateDirection(dir string) (err error) {
|
||||
|
||||
if dir != "both" && dir != "in" && dir != "out" && dir != "" {
|
||||
err = fmt.Errorf("unknown direction: %s", dir)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
module git.vsu.ru/ai/rspamd-cgp
|
||||
|
||||
go 1.23
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/jinzhu/configor v1.2.2
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/maypok86/otter v1.2.4
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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/gammazero/deque v1.0.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/gammazero/deque v1.2.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
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.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||
github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
|
||||
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
|
||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||
github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
|
||||
github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
|
||||
github.com/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/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/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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -2,55 +2,74 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
"git.vsu.ru/ai/rspamd-cgp/config"
|
||||
"git.vsu.ru/ai/rspamd-cgp/rspamc"
|
||||
"git.vsu.ru/ai/rspamd-cgp/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
var arg, cmd string
|
||||
var seq int
|
||||
var line []byte
|
||||
var err error
|
||||
var n int
|
||||
if err := cgp.InitStdoutFd(); err != nil {
|
||||
os.Stderr.WriteString("failed to init CGP stdout\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
finalize:
|
||||
loop:
|
||||
for {
|
||||
line, err = in.ReadSlice('\n')
|
||||
line, err := in.ReadSlice('\n')
|
||||
if err != nil {
|
||||
cgp.Putline("* error: %s\n", err)
|
||||
break finalize
|
||||
if err != io.EOF {
|
||||
cgp.Putline("* stdin error: ", err)
|
||||
}
|
||||
break loop
|
||||
}
|
||||
|
||||
n, err = fmt.Sscan(string(line), &seq, &cmd, &arg)
|
||||
if err != nil && err != io.EOF {
|
||||
cgp.Putline("* error: %s\n", err)
|
||||
parts := bytes.Fields(line)
|
||||
n := len(parts)
|
||||
if n < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
seq, err := strconv.Atoi(utils.Bytes2string(parts[0]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := utils.Bytes2string(parts[1])
|
||||
|
||||
switch {
|
||||
case cmd == "FILE" && n == 3:
|
||||
go rspamc.Scan(config, seq, arg)
|
||||
go rspamc.Scan(seq, string(parts[2]))
|
||||
|
||||
case cmd == "INTF" && n == 3:
|
||||
cgp.Intf(seq, arg)
|
||||
cgp.Intf(seq, utils.Bytes2string(parts[2]))
|
||||
|
||||
case cmd == "QUIT" && n == 2:
|
||||
cgp.Ok(seq)
|
||||
break finalize
|
||||
break loop
|
||||
|
||||
default:
|
||||
cgp.Putline("* bad command: %s\n", line)
|
||||
cgp.Putline("* bad command: ", line)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+221
@@ -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)
|
||||
}
|
||||
+218
-158
@@ -1,73 +1,76 @@
|
||||
package rspamc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
json "github.com/json-iterator/go"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
"git.vsu.ru/ai/rspamd-cgp/config"
|
||||
"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))
|
||||
|
||||
if v, ok := res["symbols"].(map[string]interface{})["RCPTS_DOMAINS_LOCAL"]; ok {
|
||||
if domains, ok := v.(map[string]interface{})["options"]; ok {
|
||||
for _, rcpt := range rcpts {
|
||||
for _, domain := range domains.([]interface{}) {
|
||||
if rcpt[strings.IndexByte(rcpt, '@')+1:len(rcpt)-1] == domain.(string) {
|
||||
filtered = append(filtered, rcpt)
|
||||
break
|
||||
}
|
||||
}
|
||||
if res.Symbols == nil {
|
||||
return filtered
|
||||
}
|
||||
|
||||
v, ok := res.Symbols["RCPTS_DOMAINS_LOCAL"]
|
||||
if !ok || v == nil || len(v.Options) == 0 {
|
||||
return filtered
|
||||
}
|
||||
|
||||
for _, rcpt := range rcpts {
|
||||
at := strings.IndexByte(rcpt, '@')
|
||||
if at < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rcptDomain := rcpt[at+1 : len(rcpt)-1]
|
||||
for _, domain := range v.Options {
|
||||
if rcptDomain == domain {
|
||||
filtered = append(filtered, rcpt)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isOpAccept(direction string, outbound bool) (a bool) {
|
||||
|
||||
if outbound {
|
||||
|
||||
if direction == "out" || direction == "both" {
|
||||
a = true
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if direction == "in" || direction == "both" {
|
||||
a = true
|
||||
}
|
||||
func isOpAccept(dir config.Direction, outbound bool) bool {
|
||||
switch dir {
|
||||
case config.DirBoth:
|
||||
return true
|
||||
case config.DirOut:
|
||||
return outbound
|
||||
case config.DirIn:
|
||||
return !outbound
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func makeHeaders(res map[string]interface{}) (headers []string) {
|
||||
|
||||
if _, ok := res["dkim-signature"]; ok {
|
||||
headers = append(headers, "DKIM-Signature: "+res["dkim-signature"].(string))
|
||||
func makeHeaders(res *RspamdResponse) (headers []string) {
|
||||
if res.DKIMSignature != "" {
|
||||
headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.Milter != nil && res.Milter.AddHeaders != nil {
|
||||
for h, vh := range res.Milter.AddHeaders {
|
||||
if vh.Value != "" {
|
||||
headers = append(headers, h+": "+vh.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,23 +78,21 @@ func makeHeaders(res map[string]interface{}) (headers []string) {
|
||||
return
|
||||
}
|
||||
|
||||
func makeHeadersOutbound(res map[string]interface{}) (headers []string) {
|
||||
|
||||
if _, ok := res["dkim-signature"]; ok {
|
||||
headers = append(headers, "DKIM-Signature: "+res["dkim-signature"].(string))
|
||||
func makeHeadersOutbound(res *RspamdResponse) (headers []string) {
|
||||
if res.DKIMSignature != "" {
|
||||
headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
|
||||
}
|
||||
|
||||
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 desca []string
|
||||
opsum := new(config.Operation)
|
||||
|
||||
if op, ok := conf.Actions[action]; ok {
|
||||
if isOpAccept(conf.Actions[action].Direction, conf.Outbound) {
|
||||
// Обработка Action
|
||||
if op, ok := config.Action(action); ok {
|
||||
if isOpAccept(op.Direction, config.Outbound()) {
|
||||
opsum.Discard = op.Discard
|
||||
opsum.MirrorTo = op.MirrorTo
|
||||
opsum.NotifyRcpts = op.NotifyRcpts
|
||||
@@ -104,46 +105,46 @@ func makeOpSum(conf *config.Config, res map[string]interface{}, action string) (
|
||||
desca = append(desca, action)
|
||||
}
|
||||
|
||||
if conf.Debug {
|
||||
printSelectedOp("Action", action, conf.Actions[action].Direction, conf.Outbound)
|
||||
if config.Debug() {
|
||||
printSelectedOp("Action", action, op.Direction, config.Outbound())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for symbol, op := range conf.Symbols {
|
||||
if isOpAccept(conf.Symbols[symbol].Direction, conf.Outbound) {
|
||||
if v, ok := res["symbols"].(map[string]interface{})[symbol]; ok {
|
||||
opsum.Discard = opsum.Discard || op.Discard
|
||||
opsum.MirrorTo = append(opsum.MirrorTo, op.MirrorTo...)
|
||||
opsum.NotifyRcpts = opsum.NotifyRcpts || op.NotifyRcpts
|
||||
opsum.NotifyTo = append(opsum.NotifyTo, op.NotifyTo...)
|
||||
casea = append(casea, symbol)
|
||||
// Обработка Symbols
|
||||
if res.Symbols != nil {
|
||||
for symbol, op := range config.Symbols() {
|
||||
if isOpAccept(op.Direction, config.Outbound()) {
|
||||
if v, ok := res.Symbols[symbol]; ok && v != nil {
|
||||
opsum.Discard = opsum.Discard || op.Discard
|
||||
opsum.MirrorTo = append(opsum.MirrorTo, op.MirrorTo...)
|
||||
opsum.NotifyRcpts = opsum.NotifyRcpts || op.NotifyRcpts
|
||||
opsum.NotifyTo = append(opsum.NotifyTo, op.NotifyTo...)
|
||||
casea = append(casea, symbol)
|
||||
|
||||
if len(op.Description) > 0 {
|
||||
desca = append(desca, symbol+": "+op.Description)
|
||||
} else {
|
||||
if desc, ok := v.(map[string]interface{})["description"]; ok {
|
||||
desca = append(desca, symbol+": "+desc.(string))
|
||||
if len(op.Description) > 0 {
|
||||
desca = append(desca, symbol+": "+op.Description)
|
||||
} else if v.Description != "" {
|
||||
desca = append(desca, symbol+": "+v.Description)
|
||||
} else {
|
||||
desca = append(desca, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
if conf.Debug {
|
||||
printSelectedOp("Symbol", symbol, conf.Symbols[symbol].Direction, conf.Outbound)
|
||||
if config.Debug() {
|
||||
printSelectedOp("Symbol", symbol, op.Direction, config.Outbound())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(casea) > 0 {
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(256)
|
||||
|
||||
// Формирования casework
|
||||
sb.WriteString("discard ")
|
||||
sb.WriteString(strconv.FormatBool(opsum.Discard))
|
||||
|
||||
sb.WriteString("; notifyrcpts ")
|
||||
sb.WriteString(strconv.FormatBool(opsum.NotifyRcpts))
|
||||
|
||||
@@ -165,110 +166,169 @@ func makeOpSum(conf *config.Config, res map[string]interface{}, action string) (
|
||||
return nil, "", "", ""
|
||||
}
|
||||
|
||||
func printMsgInfo(from string, rcpts []string, auth string, ip string, helo string, hostname string,
|
||||
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) {
|
||||
func printSelectedOp(optype, opname string, dir config.Direction, outbound bool) {
|
||||
|
||||
if outbound {
|
||||
fmt.Fprintf(os.Stderr, "%s '%s' selected for outbound flow: direction %s\n", optype, opname, direction)
|
||||
fmt.Fprintf(os.Stderr, "%s '%s' selected for outbound flow: direction %s\n", optype, opname, dir.String())
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "%s '%s' selected for inbound flow: direction %s\n", optype, opname, direction)
|
||||
fmt.Fprintf(os.Stderr, "%s '%s' selected for inbound flow: direction %s\n", optype, opname, dir.String())
|
||||
}
|
||||
}
|
||||
|
||||
func printResponse(v any) {
|
||||
printed, _ := json.MarshalIndent(v, "", " ")
|
||||
fmt.Fprintln(os.Stderr, string(printed), "\n")
|
||||
func printResponse(debugBuf *bytes.Buffer) {
|
||||
var pretty bytes.Buffer
|
||||
if err := json.Indent(&pretty, debugBuf.Bytes(), "", " "); err == nil {
|
||||
os.Stderr.Write(pretty.Bytes())
|
||||
os.Stderr.Write([]byte("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func procAction(seq int, qid int, opsum *config.Operation, res map[string]interface{},
|
||||
headers []string, hci string, from string, rcpts []string, body []byte, notifyfrom string,
|
||||
desc string, outbound bool, t int) {
|
||||
// procAction обрабатывает вердикт Rspamd согласно BNF:
|
||||
// SEQ [ADDHEADER "h"] [MIRRORTO "a"] {OK|DISCARD}
|
||||
func procAction(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse,
|
||||
headers []string, hci, notifyfrom, desc string, t int) {
|
||||
|
||||
var actualMirror []string
|
||||
var discard bool
|
||||
|
||||
// Уведомления
|
||||
procNotifications(seq, msg, opsum, res, hci, notifyfrom, desc)
|
||||
|
||||
// Определение намерения
|
||||
if opsum != nil {
|
||||
|
||||
if opsum.Discard {
|
||||
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 {
|
||||
to := utils.DiffSlice(opsum.MirrorTo, rcpts)
|
||||
cgp.AddHeaderWithMirrorTo(seq, qid, to, opsum.Discard, headers, body, outbound)
|
||||
}
|
||||
|
||||
if len(opsum.NotifyTo) > 0 || opsum.NotifyRcpts {
|
||||
if opsum.NotifyRcpts {
|
||||
to := make([]string, 0, len(opsum.NotifyTo)+len(rcpts))
|
||||
to = append(to, opsum.NotifyTo...)
|
||||
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 {
|
||||
cgp.NotifyTo(seq, qid, opsum.NotifyTo, hci, from, rcpts, body, notifyfrom, desc)
|
||||
}
|
||||
}
|
||||
|
||||
discard = opsum.Discard
|
||||
actualMirror = opsum.MirrorTo
|
||||
} else {
|
||||
discard = (t == 2)
|
||||
}
|
||||
|
||||
switch t {
|
||||
case 0:
|
||||
if len(headers) > 0 {
|
||||
cgp.AddHeader(seq, headers)
|
||||
} else {
|
||||
cgp.Ok(seq)
|
||||
}
|
||||
|
||||
case 1:
|
||||
cgp.AddHeader(seq, headers)
|
||||
|
||||
case 2:
|
||||
cgp.Discard(seq, qid, from, rcpts)
|
||||
// Валидация MirrorTo (Seen tag)
|
||||
if len(actualMirror) > 0 {
|
||||
if seenHdr := msg.MakeSeen(); seenHdr != "" {
|
||||
headers = append(headers, seenHdr)
|
||||
} else {
|
||||
cgp.Putline("* ", seq, " [", msg.QID, "]: warning: MirrorTo skipped (no Seen tag)")
|
||||
actualMirror = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Оптимизация ADDHEADER
|
||||
if discard && len(actualMirror) == 0 {
|
||||
headers = nil
|
||||
}
|
||||
|
||||
// Финальный ответ
|
||||
cgp.MirrorTo(seq, msg, actualMirror, headers, discard)
|
||||
}
|
||||
|
||||
// procActionRS обрабатывает Rewrite Subject через создание .sub файла в Submitted/
|
||||
func procActionRS(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse,
|
||||
headers []string, hci, notifyfrom, desc string) {
|
||||
|
||||
// Уведомления
|
||||
procNotifications(seq, msg, opsum, res, hci, notifyfrom, desc)
|
||||
|
||||
seenHdr := msg.MakeSeen()
|
||||
|
||||
// FALLBACK: Метки нет — НИКАКИХ MIRRORTO.
|
||||
// Мы только модифицируем текущее письмо (ADDHEADER) и отдаем OK.
|
||||
if seenHdr == "" {
|
||||
cgp.Putline("* ", seq, " [", msg.QID, "]: warning: RewriteSubject fallback (no Seen tag). MirrorTo suppressed.")
|
||||
|
||||
// Добавляем заголовок с предложенной темой к оригиналу
|
||||
headers = append(headers, "X-Spam-Subject: "+res.Subject)
|
||||
|
||||
// Явный вызов метода, не имеющего отношения к дублированию писем
|
||||
cgp.AddHeader(seq, headers)
|
||||
return
|
||||
}
|
||||
|
||||
// ШТАТНЫЙ РЕЖИМ: Метка есть, можем безопасно делать MIRRORTO
|
||||
headers = append(headers, seenHdr)
|
||||
|
||||
// Пишем новый файл в Submitted/
|
||||
if err := msg.RewriteSubject(headers, res.Subject); err != nil {
|
||||
cgp.Failure(seq, msg.QID, fmt.Errorf("RewriteSubject failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Удаляем оригинал.
|
||||
// Если в opsum был MirrorTo, он уйдет в этой же строке (SEQ [MIRRORTO] DISCARD).
|
||||
var actualMirror []string
|
||||
if opsum != nil {
|
||||
actualMirror = opsum.MirrorTo
|
||||
}
|
||||
|
||||
// Финальный ответ
|
||||
cgp.MirrorTo(seq, msg, actualMirror, headers, true)
|
||||
}
|
||||
|
||||
func procActionSwitch(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, headers []string, hci, action, desc string) {
|
||||
outbound := config.Outbound()
|
||||
notifyFrom := config.NotifyFrom()
|
||||
|
||||
switch action {
|
||||
case "no action":
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 0)
|
||||
|
||||
case "discard":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkR)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 2)
|
||||
|
||||
case "quarantine", "reject":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkR)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
|
||||
case "rewrite subject":
|
||||
if res.Subject != "" {
|
||||
procActionRS(seq, msg, opsum, res, headers, hci, notifyFrom, desc)
|
||||
} else {
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkA)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
}
|
||||
|
||||
case "add header":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkA)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
|
||||
case "greylist", "soft reject":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkG)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
|
||||
default:
|
||||
cgp.Failure(seq, msg.QID, fmt.Errorf("Unknown action: %v", action))
|
||||
}
|
||||
}
|
||||
|
||||
func procActionRS(seq int, qid int, opsum *config.Operation, res map[string]interface{},
|
||||
headers []string, hci string, subject string, from string, rcpts []string, body []byte,
|
||||
notifyfrom string, desc string, outbound bool) {
|
||||
func procNotifications(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, hci, notifyfrom, desc string) {
|
||||
if opsum == nil || (!opsum.NotifyRcpts && len(opsum.NotifyTo) == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
err := cgp.RewriteSubject(seq, headers, subject, qid, from, rcpts, body)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
} else {
|
||||
// Собираем всех кандидатов на получение уведомления
|
||||
to := make([]string, 0, len(opsum.NotifyTo)+len(msg.Rcpts))
|
||||
to = append(to, opsum.NotifyTo...)
|
||||
|
||||
if opsum != nil {
|
||||
cgp.AddHeaderWithMirrorTo(seq, qid, opsum.MirrorTo, opsum.Discard, headers, body, outbound)
|
||||
if len(opsum.NotifyTo) > 0 || opsum.NotifyRcpts {
|
||||
if opsum.NotifyRcpts {
|
||||
to := make([]string, 0, len(opsum.NotifyTo)+len(rcpts))
|
||||
to = append(to, opsum.NotifyTo...)
|
||||
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 {
|
||||
cgp.NotifyTo(seq, qid, opsum.NotifyTo, hci, from, rcpts, body, notifyfrom, desc)
|
||||
}
|
||||
}
|
||||
if opsum.NotifyRcpts {
|
||||
// Фильтруем только локальных или специфичных получателей на основе вердикта
|
||||
rcptsFiltered := filterLocalRcpts(msg.Rcpts, res)
|
||||
to = append(to, rcptsFiltered...)
|
||||
}
|
||||
|
||||
} else {
|
||||
cgp.Discard(seq, qid, from, rcpts)
|
||||
}
|
||||
// Убираем дубликаты и пустые строки перед отправкой
|
||||
to = utils.UniqueSliceElementsNonEmpty(to)
|
||||
|
||||
if len(to) > 0 && notifyfrom != "" {
|
||||
cgp.NotifyTo(seq, msg, to, hci, notifyfrom, desc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+99
-105
@@ -4,160 +4,154 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
"git.vsu.ru/ai/rspamd-cgp/config"
|
||||
)
|
||||
|
||||
const (
|
||||
headerCase string = "X-Rspamd-Case: "
|
||||
headerJunkG string = "X-Junk-Score: [XX]"
|
||||
headerJunkA string = "X-Junk-Score: [XXXX]"
|
||||
headerJunkR string = "X-Junk-Score: [XXXXXXXXXX]"
|
||||
type Duration float64
|
||||
|
||||
func (d Duration) Append(dst []byte) []byte {
|
||||
return strconv.AppendFloat(dst, float64(d), 'f', 6, 64)
|
||||
}
|
||||
|
||||
type RspamdResponse struct {
|
||||
Action string `json:"action"`
|
||||
Score float64 `json:"score"`
|
||||
RequiredScore float64 `json:"required_score"`
|
||||
TimeReal Duration `json:"time_real"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
DKIMSignature string `json:"dkim-signature,omitempty"`
|
||||
|
||||
// Milter может отсутствовать (nil), если нет действий
|
||||
Milter *struct {
|
||||
AddHeaders map[string]struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"add_headers"`
|
||||
} `json:"milter,omitempty"`
|
||||
|
||||
// Symbols могут отсутствовать (nil)
|
||||
Symbols map[string]*Symbol `json:"symbols,omitempty"`
|
||||
}
|
||||
|
||||
type Symbol struct {
|
||||
Score float64 `json:"score"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
client *http.Client
|
||||
clientOne sync.Once
|
||||
)
|
||||
|
||||
func Scan(conf *config.Config, seq int, filename string) {
|
||||
func Scan(seq int, filename string) {
|
||||
|
||||
tr := &http.Transport{
|
||||
DisableCompression: true,
|
||||
}
|
||||
clientOne.Do(func() {
|
||||
client = &http.Client{
|
||||
Timeout: config.Timeout(),
|
||||
Transport: &http.Transport{
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: conf.Timeout,
|
||||
Transport: tr,
|
||||
}
|
||||
|
||||
from, rcpts, auth, ip, helo, hostname, qid, body, seen, err := cgp.Message(filename)
|
||||
msg, err := cgp.NewMessage(seq, filename)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
cgp.Failure(seq, 0, err)
|
||||
return
|
||||
}
|
||||
defer msg.Close()
|
||||
|
||||
if msg.IsSeen() {
|
||||
cgp.OkSeen(seq, msg.QID)
|
||||
return
|
||||
}
|
||||
|
||||
if seen {
|
||||
cgp.OkSeen(seq, qid)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", conf.Host, bytes.NewReader(body))
|
||||
content := io.NewSectionReader(msg.File, msg.HdrPos, msg.Size-msg.HdrPos)
|
||||
req, err := http.NewRequest("POST", config.Host(), content)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
cgp.Failure(seq, msg.QID, err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Add("MTA-Tag", conf.AuthservId)
|
||||
req.Header.Add("MTA-Tag", config.AuthservId())
|
||||
req.Header.Add("User-Agent", "rspamd-cgp")
|
||||
req.Header.Add("From", from)
|
||||
req.Header.Add("Queue-ID", strconv.Itoa(qid))
|
||||
if len(auth) > 0 {
|
||||
req.Header.Add("User", auth)
|
||||
req.Header.Add("From", msg.From)
|
||||
req.Header.Add("Queue-ID", strconv.Itoa(msg.QID))
|
||||
if len(msg.Auth) > 0 {
|
||||
req.Header.Add("User", msg.Auth)
|
||||
}
|
||||
if len(ip) > 0 {
|
||||
req.Header.Add("IP", ip)
|
||||
if len(msg.IP) > 0 {
|
||||
req.Header.Add("IP", msg.IP)
|
||||
}
|
||||
if len(helo) > 0 {
|
||||
req.Header.Add("Helo", helo)
|
||||
if len(msg.Helo) > 0 {
|
||||
req.Header.Add("Helo", msg.Helo)
|
||||
}
|
||||
if len(hostname) > 0 {
|
||||
req.Header.Add("Hostname", hostname)
|
||||
if len(msg.Hostname) > 0 {
|
||||
req.Header.Add("Hostname", msg.Hostname)
|
||||
}
|
||||
for _, rcpt := range rcpts {
|
||||
for _, rcpt := range msg.Rcpts {
|
||||
req.Header.Add("Rcpt", rcpt)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
cgp.Failure(seq, msg.QID, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
rbody, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
var res RspamdResponse
|
||||
var reader io.Reader = resp.Body
|
||||
var debugBuf *bytes.Buffer
|
||||
|
||||
// Если Debug включен, копируем поток в буфер
|
||||
if config.Debug() {
|
||||
debugBuf = new(bytes.Buffer)
|
||||
reader = io.TeeReader(resp.Body, debugBuf)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(reader).Decode(&res); err != nil {
|
||||
cgp.Failure(seq, msg.QID, err)
|
||||
return
|
||||
}
|
||||
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(rbody, &res); err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
if config.Debug() {
|
||||
msg.PrintMsgInfo()
|
||||
printResponse(debugBuf)
|
||||
}
|
||||
|
||||
action := res.Action
|
||||
if action == "" {
|
||||
cgp.Failure(seq, msg.QID, fmt.Errorf("missing or invalid 'action' field"))
|
||||
return
|
||||
}
|
||||
|
||||
if conf.Debug {
|
||||
printMsgInfo(from, rcpts, auth, ip, helo, hostname, qid, seen)
|
||||
printResponse(res)
|
||||
}
|
||||
opsum, caseinfo, casework, desc := makeOpSum(&res, action)
|
||||
|
||||
action := res["action"].(string)
|
||||
opsum, caseinfo, casework, desc := makeOpSum(conf, res, action)
|
||||
|
||||
headers := make([]string, 0, 8)
|
||||
|
||||
if conf.Outbound {
|
||||
headers = makeHeadersOutbound(res)
|
||||
var headers []string
|
||||
if config.Outbound() {
|
||||
headers = makeHeadersOutbound(&res)
|
||||
} else {
|
||||
headers = makeHeaders(res)
|
||||
headers = makeHeaders(&res)
|
||||
}
|
||||
|
||||
cgp.Putline("* %d [%d]: Action: %s; Score: %.2f/%.2f; Time elapsed: %.3fs\n",
|
||||
seq, qid, action, res["score"], res["required_score"], res["time_real"])
|
||||
cgp.Putline("* ", seq, " [", msg.QID, "]: Action: ", action,
|
||||
"; Score: ", res.Score, "/", res.RequiredScore, "; Time elapsed: ", res.TimeReal)
|
||||
|
||||
var hci string
|
||||
|
||||
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
|
||||
if !conf.Outbound {
|
||||
if !config.Outbound() {
|
||||
headers = append(headers, hci)
|
||||
}
|
||||
}
|
||||
|
||||
switch action {
|
||||
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))
|
||||
}
|
||||
procActionSwitch(seq, msg, opsum, &res, headers, hci, action, desc)
|
||||
}
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@
|
||||
# Устанавливает значение authserv-id в заголовке Authentication-Results, RFC7001
|
||||
# Если не задан, ему присваивается имя главного домена CommuniGate Pro.
|
||||
#
|
||||
#authservid: mx.domain.ru
|
||||
authservid: mx.domain.name
|
||||
|
||||
################################################################################
|
||||
# Адрес хоста и порт Rspamd, тайм-аут.
|
||||
@@ -17,7 +17,7 @@ timeout: 15s
|
||||
################################################################################
|
||||
# Устанавливает значение заголовка From: в оповещениях.
|
||||
#
|
||||
notifyfrom: rspamd-cgp-notify@domain.ru
|
||||
notifyfrom: rspamd-cgp-notify@domain.name
|
||||
|
||||
################################################################################
|
||||
# Секция описывает действия (actions) и дополнительную обработку для каждого
|
||||
|
||||
+5
-30
@@ -1,39 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func DiffSlice[T comparable](s1, s2 []T) []T {
|
||||
|
||||
s2m := make(map[T]bool, len(s2))
|
||||
for _, v := range s2 {
|
||||
s2m[v] = true
|
||||
func Bytes2string(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
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()
|
||||
return unsafe.String(unsafe.SliceData(b), len(b))
|
||||
}
|
||||
|
||||
func UniqueSliceElementsNonEmpty[T ~string](s []T) []T {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user