45 Commits

Author SHA1 Message Date
ai 185637b990 форматирование строк 2026-03-11 01:17:10 +03:00
ai 7ba94253e3 форматирование оповещения 2026-03-10 12:49:01 +03:00
ai be1849681d корретировка README 2026-03-10 11:35:21 +03:00
ai ee953c1b60 typo 2026-03-10 11:27:59 +03:00
ai d5b7405971 обраболтка пустого mail from 2026-03-10 10:34:50 +03:00
ai 0cad04da66 исправление тестов, в связи с изменением <> вокруг email 2026-03-09 11:52:15 +03:00
ai 7157abd56c удаление <> вокруг email, теперь в rspamd адреса уходят чистыми 2026-03-09 11:41:49 +03:00
ai f00cfd4153 корректировка комментариев и пробелов 2026-03-09 11:22:44 +03:00
ai b823f4f0a3 remove dead code 2026-03-07 16:19:23 +03:00
ai 02d6e8fa06 добавлен ключ -version 2026-03-07 14:29:59 +03:00
ai 6c86bbf6b0 fmt 2026-03-06 22:45:31 +03:00
ai 1d9e6b5b33 исправление ошибок парсинга письма 2026-03-06 20:23:39 +03:00
ai 9401266553 fmt 2026-03-06 11:39:33 +03:00
ai 3c5aef25e9 использование обезличенного домена в тестах 2026-03-06 11:38:18 +03:00
ai ebb759cb30 fmt 2026-03-06 10:09:54 +03:00
ai fd76bb19f8 печать в секундах 2026-03-06 02:25:12 +03:00
ai 8127c512ce тип Duration и метод Append 2026-03-06 02:20:47 +03:00
ai 8373f81f24 поддержка Appender при печати 2026-03-06 02:19:24 +03:00
ai fcaa4b1a9d float32 -> float64 2026-03-06 01:38:35 +03:00
ai 53ec580c99 поддержка float, увеличение буфера 2026-03-06 01:31:05 +03:00
ai 6f36f283e2 добавлены переключатели языка 2026-03-06 01:04:50 +03:00
ai ccb1ccc20e добавлен README на английском 2026-03-06 00:55:46 +03:00
ai ac6fdff1dd изменена ссылка на документацию CommuniGate Pro 2026-03-06 00:54:50 +03:00
ai 0816768341 обновление README 2026-03-05 23:56:54 +03:00
ai 2add0e8caa добавление бенчмарка 2026-03-05 23:38:22 +03:00
ai a83623e6da исправление тестов 2026-03-05 23:37:44 +03:00
ai 4cf590ef5f perf!: streaming I/O refactoring, full test coverage, and v3.0.0
- Implemented streaming processing for constant memory footprint.
- Optimized memory usage: reduced allocations from 185 to 99.
- Migrated `config.Direction` to typed Enum.
- Added comprehensive test suite for config, cgp, rspamc and internal logic.
- Cleaned up loop protection and action handlers.
2026-03-05 23:19:40 +03:00
ai 37fa8a3d09 начало двуязычного README 2026-03-05 23:00:32 +03:00
ai 6686a722b8 тесты для utils 2026-03-05 22:33:03 +03:00
ai 9ea6e3a535 изменение домена 2026-03-05 22:32:14 +03:00
ai 938ff7d8c9 добавлена функция Bytes2string 2026-03-05 22:31:28 +03:00
ai d3c34f4414 обновление зависимостей 2026-03-05 22:30:23 +03:00
ai f5d60164be bench 2026-03-05 22:29:31 +03:00
ai ca99deb5e1 оптимизация stdoutFd 2026-03-01 11:00:19 +03:00
ai b074a371f5 рефакторинг 2026-03-01 10:59:13 +03:00
ai 40ba8f6878 изменение комментария 2026-02-25 01:54:08 +03:00
ai 2b8d64c89e рефакторинг, пересечение имён с константой 2026-02-25 01:53:20 +03:00
ai 659d1bf531 рефакторинг 2026-02-23 13:58:31 +03:00
ai 9a4226ceea добавлена функция extractAngle, рефакторинг обработки ошибок 2026-02-23 13:57:02 +03:00
ai aed98832e4 рефакторинг, проверка приведения типов 2026-02-23 13:55:27 +03:00
ai ae3586e0cc проверка приведения типов, оптимизация 2026-02-23 13:52:40 +03:00
ai 3ef6db6a7d переписано на параллельное выполнение запросов 2026-02-23 13:50:47 +03:00
ai c5a5acbd4f обновление версии go и зависимостей 2026-02-23 13:48:54 +03:00
ai 489783d652 обновление лицензии 2026-02-23 13:48:02 +03:00
ai 7462a3de80 обновление зависимостей 2025-04-14 21:29:02 +03:00
29 changed files with 2995 additions and 1110 deletions
+23 -22
View File
@@ -1,26 +1,27 @@
Copyright (c) 2021-2026, Andrey Igoshin <ai@vsu.ru>
All rights reserved.
Redistribution and use in source and binary forms, with or without
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.
+42 -40
View File
@@ -1,58 +1,60 @@
[![English](https://img.shields.io/badge/lang-en-blue.svg)](#) [![Russian](https://img.shields.io/badge/lang-ru-lightgrey.svg)](README.ru.md)
## Rspamd helper for CommuniGate Pro 6.x, *версия 2.0.0*
## Rspamd helper for CommuniGate Pro 6.x, *version 3.0.0*
### Введение
### Introduction
*Помощник* (внешний фильтр сообщений) **rspamd-cgp** используется для фильтрования спама. Он получает сообщения от *CommuniGate Pro* и передаёт их для обработки в *Rspamd*.<br/><br/>
The **rspamd-cgp** *Helper* (external message filter) is designed for spam filtering. It receives messages from *CommuniGate Pro* and passes them to *Rspamd* for processing.<br/><br/>
### Возможности
### Features
* *Помощник* получает сообщение от *CommuniGate Pro* по протоколу *Интерфейса Внешнего Фильтра* и передаёт сообщение в *Rspamd*, используя *Rspamd protocol*.
* **High Performance:** Starting with version 3.0.0, the *Helper* utilizes *streaming* data processing. This ensures near-constant memory consumption and a significant reduction in allocations, regardless of the processed message size.
* Если сообщение получено из аутентифицированного источника, *Помощник* передаёт заголовок *Auth:* в *Rspamd protocol*.
* The *Helper* receives messages from *CommuniGate Pro* via the *External Filter Interface* protocol and transmits them to *Rspamd* using the *Rspamd protocol*.
* *Помощник* определяет *HELO/EHLO* протокола *SMTP* на основании первого заголовка *Received:* сообщения и передаёт заголовок *Helo:* в *Rspamd protocol*.
* If a message is received from an authenticated source, the *Helper* passes the `Auth:` header in the *Rspamd protocol*.
* *Помощник* выполняет резольвинг ip-адреса источника сообщения, и, если резольвинг успешен, передаёт заголовок *Hostname:* в *Rspamd protocol*. Результаты резольвинга кешируются.
* The *Helper* determines the SMTP `HELO/EHLO` identity based on the first `Received:` header of the message and passes the `Helo:` header in the *Rspamd protocol*.
* *Помощник* различает сообщения, полученные из внешних источников, и сгенерированные *CommuniGate Pro*. При передаче в *Rspamd* сгенерированных сообщений *Помощник* помечает их как полученные из доверенного источника.
* The *Helper* performs reverse DNS lookup for the message source IP address. If successful, it passes the `Hostname:` header in the *Rspamd protocol*. Lookup results are cached.
* *Помощник* формирует заголовок *X-Junk-Score:* на основании *action*, пришедшего от *Rspamd*. Этот заголовок обрабатывается встроенными правилами *Управление Спамом*, которые настраиваются в пользовательском интерфейсе *CommuniGate Pro*.
* The *Helper* distinguishes between messages received from external sources and those generated by *CommuniGate Pro*. When passing generated messages to *Rspamd*, the *Helper* marks them as originating from a trusted source.
* *Помощник* добавляет в сообщение все полученные от *Rspamd* заголовки. Решение о добавлении этих заголовков принимается на стороне *Rspamd*.
* The *Helper* generates the `X-Junk-Score:` header based on the *action* returned by *Rspamd*. This header is processed by the built-in *Spam Management* rules configured in the *CommuniGate Pro* user interface.
* При получении *action: rewrite subject* *Помощник* переписывает *Subject:* сообщения как указано в ответе *Rspamd*. Эта операция вызывает проход сообщения через *CommuniGate Pro PIPE*.
* The *Helper* adds all headers received from *Rspamd* to the message. The decision to include these headers is made on the *Rspamd* side.
* В ряде случаев, чтобы избежать двойной обработки или зацикливания сообщений, *Помощник* добавляет в обработанное сообщение заголовок *X-Rspamd-Seen:*.
* Upon receiving an `action: rewrite subject`, the *Helper* rewrites the message `Subject:` as specified in the *Rspamd* response. This operation triggers the message to pass through the *CommuniGate Pro PIPE*.
* *Помощник* может выполнять дополнительную обработку сообщений на основании своего конфигурационного файла, используя данные из ответа *Rspamd*. Дополнительная обработка может быть применена к *действиям* и *символам*. Если для конкретного сообщения в конфигурационном файле *Помощника* совпали несколько *действий* и *символов*, для получения итогового результата они суммируются.
* In certain scenarios, to prevent double processing or message looping, the *Helper* adds an `X-Rspamd-Seen:` header to the processed message.
* *Помощник* может обрабатывать исходящие сообщения. Это позволяет задействовать ряд модулей *Rspamd* для уменьшения ложных срабатываний (FP).
* The *Helper* can perform additional message processing based on its configuration file using data from the *Rspamd* response. Additional processing can be applied to both *actions* and *symbols*. If multiple *actions* and *symbols* match in the configuration file for a specific message, the results are aggregated.
* The *Helper* can process outbound messages. This allows utilizing various *Rspamd* modules to reduce False Positives (FP).
### Установка
### Installation
Конфигурационный файл *Помощника* [rspamd-cgp.yml](rspamd-cgp.yml) по умолчанию находится в той же директории, что и исполняемый файл **rspamd-cgp**. При необходимости другое местоположение конфигурационного файла можно указать в командной строке. Возможные настройки *Помощника* подробно описаны в конфигурационном файле.
The *Helper* configuration file [rspamd-cgp.yml](rspamd-cgp.yml) is located by default in the same directory as the **rspamd-cgp** executable. An alternative configuration file path can be specified via the command line. All available settings are described in detail within the configuration file.
> Ниже показаны настройки *Помощника* и *Правила* в интерфейсе *CommuniGate Pro*.
> Below are the *Helper* settings and *Rules* within the *CommuniGate Pro* interface.
#### Для входящих сообщений
#### For Inbound Messages
##### Установки -> Общее -> Помощники
##### Settings -> General -> Helpers
![](./helper-in.png)
##### Установки -> Почта -> Правила -> RSPAMD_in
##### Settings -> Mail -> Rules -> RSPAMD_in
![](./rule-in.png)
#### Для исходящих сообщений
#### For Outbound Messages
##### Установки -> Общее -> Помощники
##### Settings -> General -> Helpers
![](./helper-out.png)
##### Установки -> Почта -> Правила -> RSPAMD_out
##### Settings -> Mail -> Rules -> RSPAMD_out
![](./rule-out.png)
#### Параметры командной строки
#### 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
View File
@@ -0,0 +1,112 @@
[![English](https://img.shields.io/badge/lang-en-lightgrey.svg)](README.md) [![Russian](https://img.shields.io/badge/lang-ru-red.svg)](#)
## Rspamd helper for CommuniGate Pro 6.x, *версия 3.0.0*
### Введение
*Помощник* (внешний фильтр сообщений) **rspamd-cgp** используется для фильтрования спама. Он получает сообщения от *CommuniGate Pro* и передаёт их для обработки в *Rspamd*.<br/><br/>
### Возможности
* **Высокая производительность:** Начиная с версии 3.0.0, *Помощник* использует потоковую (*streaming*) обработку данных. Это позволило добиться практически константного потребления памяти и значительного снижения количества аллокаций, независимо от размера обрабатываемого сообщения.
* *Помощник* получает сообщение от *CommuniGate Pro* по протоколу *Интерфейса Внешнего Фильтра* и передаёт сообщение в *Rspamd*, используя *Rspamd protocol*.
* Если сообщение получено из аутентифицированного источника, *Помощник* передаёт заголовок *Auth:* в *Rspamd protocol*.
* *Помощник* определяет *HELO/EHLO* протокола *SMTP* на основании первого заголовка *Received:* сообщения и передаёт заголовок *Helo:* в *Rspamd protocol*.
* *Помощник* выполняет резольвинг ip-адреса источника сообщения, и, если резольвинг успешен, передаёт заголовок *Hostname:* в *Rspamd protocol*. Результаты резольвинга кешируются.
* *Помощник* различает сообщения, полученные из внешних источников, и сгенерированные *CommuniGate Pro*. При передаче в *Rspamd* сгенерированных сообщений *Помощник* помечает их как полученные из доверенного источника.
* *Помощник* формирует заголовок *X-Junk-Score:* на основании *action*, пришедшего от *Rspamd*. Этот заголовок обрабатывается встроенными правилами *Управление Спамом*, которые настраиваются в пользовательском интерфейсе *CommuniGate Pro*.
* *Помощник* добавляет в сообщение все полученные от *Rspamd* заголовки. Решение о добавлении этих заголовков принимается на стороне *Rspamd*.
* При получении *action: rewrite subject* *Помощник* переписывает *Subject:* сообщения как указано в ответе *Rspamd*. Эта операция вызывает проход сообщения через *CommuniGate Pro PIPE*.
* В ряде случаев, чтобы избежать двойной обработки или зацикливания сообщений, *Помощник* добавляет в обработанное сообщение заголовок *X-Rspamd-Seen:*.
* *Помощник* может выполнять дополнительную обработку сообщений на основании своего конфигурационного файла, используя данные из ответа *Rspamd*. Дополнительная обработка может быть применена к *действиям* и *символам*. Если для конкретного сообщения в конфигурационном файле *Помощника* совпали несколько *действий* и *символов*, для получения итогового результата они суммируются.
* *Помощник* может обрабатывать исходящие сообщения. Это позволяет задействовать ряд модулей *Rspamd* для уменьшения ложных срабатываний (FP).
### Установка
Конфигурационный файл *Помощника* [rspamd-cgp.yml](rspamd-cgp.yml) по умолчанию находится в той же директории, что и исполняемый файл **rspamd-cgp**. При необходимости другое местоположение конфигурационного файла можно указать в командной строке. Возможные настройки *Помощника* подробно описаны в конфигурационном файле.
> Ниже показаны настройки *Помощника* и *Правила* в интерфейсе *CommuniGate Pro*.
#### Для входящих сообщений
##### Установки -> Общее -> Помощники
![](./helper-in.png)
##### Установки -> Почта -> Правила -> RSPAMD_in
![](./rule-in.png)
#### Для исходящих сообщений
##### Установки -> Общее -> Помощники
![](./helper-out.png)
##### Установки -> Почта -> Правила -> RSPAMD_out
![](./rule-out.png)
#### Параметры командной строки
```
Usage of rspamd-cgp:
-config string
Set configuration file (default "rspamd-cgp.yml")
-configdump
Perform configuration file dump
-configtest
Perform configuration file test
-debug
Run in debug mode
-outbound
Outbound message flow processing
-version
Print version and exit
```
**config**
Указывает альтернативный конфигурационный файл.
**configdump**
Выводит конфигурационный файл в форматированном виде.
**configtest**
Проверяет синтаксическую корректность конфигурационного файла.
**debug**
Выводит в форматированном виде ответ *Rspamd* (JSON). Может быть использован для контроля возвращаемых *Rspamd* *символов* и других данных. Входной файл должен быть в формате файла очереди *CommuniGate Pro*. ***Применять только при запуске из командной строки!!!***
**outbound**
Обрабатывает поток исходящих сообщений. Если исходящие сообщения отправляются на внешние MTA, в них не добавляются заголовки, являющиеся результатом проверки на спам. Какие именно сообщения обрабатываются таким образом определяется *Правилом* *CommuniGate Pro*.
<br/>
### Лицензия
BSD License, [LICENSE.md](LICENSE.md)<br/><br/>
### Автор
Andrey Igoshin <<ai@vsu.ru>><br/><br/>
### Ссылки
* Репозиторий: <https://git.vsu.ru/ai/rspamd-cgp>
* Сайт CommuniGate Pro: <https://communigatepro.ru>
* Протокол Помощника: <https://doc.communigatepro.ru/russian/development/Helpers.html#Filters>
* Сайт Rspamd: <https://rspamd.com>
* Протокол Rspamd: <https://rspamd.com/doc/developers/protocol.html>
+45 -18
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,141 @@
package cgp
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMainDomain(t *testing.T) {
tmpDir := t.TempDir()
err := os.MkdirAll(filepath.Join(tmpDir, "Settings"), 0755)
if err != nil {
t.Fatal(err)
}
// Тестируем разные варианты написания в Main.settings
content := []byte(`
OtherKey = 123;
DomainName = "relay1.domain.name" ;
UnquotedDomain = domain.name;
# CommentedDomain = ignore.me;
`)
settingsPath := filepath.Join(tmpDir, "Settings", "Main.settings")
if err := os.WriteFile(settingsPath, content, 0644); err != nil {
t.Fatal(err)
}
// Подменяем рабочую директорию
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
t.Run("Extract Quoted Domain", func(t *testing.T) {
got, err := MainDomain()
if err != nil {
t.Fatalf("MainDomain failed: %v", err)
}
if got != "relay1.domain.name" {
t.Errorf("Got %q, want %q", got, "relay1.domain.name")
}
})
}
func TestProtocolCommands(t *testing.T) {
// Создаем pipe, чтобы перехватить то, что функции пишут в stdoutFd
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
// Сохраняем старый дескриптор и подменяем на наш pipe
oldFd := stdoutFd
stdoutFd = int(w.Fd())
defer func() { stdoutFd = oldFd }()
tests := []struct {
name string
fn func()
want string
}{
{
name: "Ok command",
fn: func() { Ok(123) },
want: "123 OK\n",
},
{
name: "Discard command",
fn: func() { Discard(456) },
want: "456 DISCARD\n",
},
{
name: "Reject command",
fn: func() { Reject(789) },
want: "789 REJECT Try again later\n",
},
{
name: "Failure command",
fn: func() { Failure(10, 200, fmt.Errorf("test error")) },
want: "* 10 [200]: test error\n10 FAILURE\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.fn()
buf := make([]byte, 256)
n, _ := r.Read(buf)
got := string(buf[:n])
if got != tt.want {
t.Errorf("Got %q, want %q", got, tt.want)
}
})
}
}
func TestAddHeader_ProtocolHandling(t *testing.T) {
r, w, _ := os.Pipe()
oldFd := stdoutFd
stdoutFd = int(w.Fd())
defer func() { stdoutFd = oldFd }()
t.Run("Protocol v4 with OK", func(t *testing.T) {
protocol = 4
AddHeader(1, []string{"X-Test: True"})
buf := make([]byte, 256)
n, _ := r.Read(buf)
got := string(buf[:n])
if !strings.Contains(got, "\" OK\n") {
t.Errorf("Protocol v4 should append OK, got: %q", got)
}
})
}
func TestPutline(t *testing.T) {
r, w, _ := os.Pipe()
oldFd := stdoutFd
stdoutFd = int(w.Fd())
defer func() { stdoutFd = oldFd }()
t.Run("Variadic mixed types", func(t *testing.T) {
Putline("* ", 1, " error: ", fmt.Errorf("fail"))
buf := make([]byte, 256)
n, _ := r.Read(buf)
got := string(buf[:n])
want := "* 1 error: fail\n"
if got != want {
t.Errorf("Putline mismatch. Got %q, want %q", got, want)
}
})
}
+78 -33
View File
@@ -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
}
+50
View File
@@ -0,0 +1,50 @@
package cgp
import (
"testing"
)
func TestHostname_Logic(t *testing.T) {
t.Run("FilterNames", func(t *testing.T) {
input := []string{"host", "mail.domain.name", "localhost", "deep.sub.domain.com"}
// Должны остаться только те, что проходят IsValidDomain (с точками)
got := filterNames(input)
if len(got) != 3 { // mail.domain.name, localhost (если rx пропустит), deep...
// Зависит от вашего regex. IsValidDomain требует хотя бы одну точку.
t.Logf("Filtered names: %v", got)
}
})
t.Run("FindShorterItem", func(t *testing.T) {
input := []string{"very-long-hostname.example.com", "short.com", "medium.example.com"}
want := "short.com"
if got := findShorterItem(input); got != want {
t.Errorf("findShorterItem() = %v, want %v", got, want)
}
})
t.Run("FindLongerItem", func(t *testing.T) {
input := []string{"a.ru", "abc.ru", "ab.ru"}
want := "abc.ru"
if got := findLongerItem(input); got != want {
t.Errorf("findLongerItem() = %v, want %v", got, want)
}
})
t.Run("IsValidDomain", func(t *testing.T) {
tests := []struct {
dom string
want bool
}{
{"domain.name", true},
{"mail.domain.name.", true}, // с точкой в конце
{"internal-host", false}, // нет точек
{"1.2.3.4", false}, // IP не должен быть доменом в этой логике
}
for _, tt := range tests {
if got := IsValidDomain(tt.dom); got != tt.want {
t.Errorf("IsValidDomain(%q) = %v, want %v", tt.dom, got, tt.want)
}
}
})
}
+88 -221
View File
@@ -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)
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package cgp
import (
"bufio"
"bytes"
"strings"
"testing"
)
func TestGetHeader_Folding(t *testing.T) {
// Проверяем корректность сборки многострочных заголовков (RFC folding)
raw := "Subject: This is a very long\n\t subject line\nNext-Header: value\n"
rd := bufio.NewReader(strings.NewReader(raw))
buf := new(bytes.Buffer)
err := getHeader(rd, buf)
if err != nil {
t.Fatalf("getHeader failed: %v", err)
}
got := buf.String()
// Должен захватить обе строки, так как вторая начинается с табуляции
if !strings.Contains(got, "subject line") {
t.Errorf("Header folding failed. Got: %q", got)
}
if strings.Contains(got, "Next-Header") {
t.Error("getHeader read too far into the next header")
}
}
func TestGetHelo_Variants(t *testing.T) {
tests := []struct {
name string
hdr string
want string
}{
{"Standard verified", "Received: from mail.domain.name ([1.2.3.4] verified)\n", "mail.domain.name"},
{"HELO in brackets", "Received: from [1.2.3.4] (HELO poseidonms.com)\n", "poseidonms.com"},
{"Account and HELO", "Received: from [10.19.5.40] (account test@domain.name HELO test.domain.name)\n", "test.domain.name"},
{"Malformed", "Received: from broken (", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getHelo([]byte(tt.hdr)); got != tt.want {
t.Errorf("getHelo() = %q, want %q", got, tt.want)
}
})
}
}
func TestReplaceSpecCharsBuf(t *testing.T) {
buf := new(bytes.Buffer)
input := "Path\\To\nFile\t\"Name\"\r"
// \ -> \\, \n -> \e, \t -> \t, " -> \", \r -> skip
expected := "Path\\\\To\\eFile\\t\\\"Name\\\""
replaceSpecCharsBuf(buf, input)
if buf.String() != expected {
t.Errorf("replaceSpecCharsBuf failed.\nGot: %q\nWant: %q", buf.String(), expected)
}
}
func TestIsHeaderEnd(t *testing.T) {
if !isHeaderEnd([]byte("\n")) {
t.Error("Failed to detect LF as header end")
}
if !isHeaderEnd([]byte("\r\n")) {
t.Error("Failed to detect CRLF as header end")
}
if isHeaderEnd([]byte("Subject: \n")) {
t.Error("False positive for header end")
}
}
+475
View File
@@ -0,0 +1,475 @@
package cgp
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"
"github.com/cespare/xxhash/v2"
"git.vsu.ru/ai/rspamd-cgp/utils"
)
type Message struct {
File *os.File
QID int
From string
Rcpts []string
Auth string
IP string
Helo string
Hostname string
HdrPos int64 // Смещение до RFC заголовков (после конверта)
BodyPos int64 // Смещение до RFC тела (после заголовков)
Size int64 // Общий размер файла
seen bool
extReceived []byte
}
var (
readerPool = sync.Pool{
New: func() any { return bufio.NewReaderSize(nil, 4096) },
}
writerPool = sync.Pool{
New: func() any { return bufio.NewWriterSize(nil, 4096) },
}
)
var (
bRecvHdr = []byte("Received: ")
bRecvFrom = []byte("Received: from ")
bSeenHdr = []byte("X-Rspamd-Seen: ")
bSubjHdr = []byte("Subject: ")
)
var (
// Тип источника: 1 - Внешний (SMTP/HTTP...), 2 - Внутренний (RULE/LIST...)
sourceModules = map[string]int{
"SMTP": 1, "HTTP": 1, "HTTPU": 1, "AIRSYNC": 1, "IMAP": 1, "RPOP": 1, "XIMSS": 1,
"ALARM": 2, "DSN": 2, "GROUP": 2, "ICAL": 2, "LIST": 2, "LSTM": 2, "LSTO": 2,
"MDN": 2, "PBX": 2, "PIPE": 2, "RULE": 2, "WEBUSER": 2,
}
)
func (m *Message) Close() error {
return m.File.Close()
}
func (m *Message) IsSeen() bool {
return m.seen
}
func (m *Message) MakeSeen() string {
// Если внешних Received не найдено (внутреннее письмо),
// возвращаем пустую строку, чтобы заголовок не добавлялся.
if len(m.extReceived) == 0 {
return ""
}
// bSeenHdr = []byte("X-Rspamd-Seen: ")
// Собираем строку: имя заголовка + хеш от сохраненного оригинала
return string(bSeenHdr) + hashReceived(m.extReceived)
}
func NewMessage(seq int, filename string) (*Message, error) {
// 1. Извлечение QID из имени файла (например, 12345.msg -> 12345)
start := strings.LastIndexByte(filename, '/') + 1
end := strings.LastIndexByte(filename, '.')
if end <= start {
return nil, fmt.Errorf("invalid filename: %s", filename)
}
qid, err := strconv.Atoi(filename[start:end])
if err != nil {
return nil, fmt.Errorf("parse QID failed: %w", err)
}
// 2. Открытие файла оригинала
f, err := os.Open(filename)
if err != nil {
return nil, err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
msg := &Message{
File: f,
QID: qid,
Size: fi.Size(),
}
// 3. Подготовка ридера из пула
rd := getReader(f)
defer putReader(rd)
var pos int64
// 4. Читаем Конверт (Envelope)
for {
line, err := rd.ReadSlice('\n')
if err != nil {
if err == io.EOF {
err = fmt.Errorf("unexpected end of envelope")
}
f.Close()
return nil, err
}
pos += int64(len(line))
if isHeaderEnd(line) {
break
}
// Парсинг параметров конверта
switch line[0] {
case 'P': // Return-Path
if v, ok := extractAngle(line); ok {
msg.From = v
}
case 'R': // Envelope-To
if v, ok := extractAngle(line); ok {
msg.Rcpts = append(msg.Rcpts, v)
}
case 'S': // Source/Interface info
parseSource(seq, line, msg)
}
}
msg.HdrPos = pos
// 5. Разбор Заголовков (Headers)
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
var (
seenSum string
seenFound bool
seenProcessed bool
extReceivedSaved bool
)
for {
buf.Reset()
err := getHeader(rd, buf)
hdr := buf.Bytes()
if len(hdr) > 0 {
pos += int64(len(hdr))
if isHeaderEnd(hdr) {
break
}
// А. Проверка существующей метки Seen (Прошлое)
// Доверяем порядку: Метка -> (опционально не-Received) -> Первый Received
if !seenProcessed {
if !seenFound {
if bytes.HasPrefix(hdr, bSeenHdr) {
seenSum = string(bytes.TrimSpace(hdr[len(bSeenHdr):]))
seenFound = true
}
} else if bytes.HasPrefix(hdr, bRecvHdr) {
// Валидируем по первому же встречному Received
if hashReceived(hdr) == seenSum {
msg.seen = true
}
seenProcessed = true
}
}
// Б. Идентификация точки входа (Будущее)
// Нас интересует только первый внешний Received.
if !extReceivedSaved && isExternal(hdr) {
msg.extReceived = bytes.Clone(hdr)
extReceivedSaved = true
msg.Helo = getHelo(hdr)
}
}
if err != nil {
if err == io.EOF {
break
}
f.Close()
return nil, err
}
}
msg.BodyPos = pos
// Очистка списка получателей от теоретических дублей
msg.Rcpts = utils.UniqueSliceElementsNonEmpty(msg.Rcpts)
return msg, nil
}
func (m *Message) PrintMsgInfo() {
fmt.Fprintln(os.Stderr, "from: ", m.From)
fmt.Fprintln(os.Stderr, "rcpts: ", m.Rcpts)
fmt.Fprintln(os.Stderr, "ip: ", m.IP)
fmt.Fprintln(os.Stderr, "helo: ", m.Helo)
fmt.Fprintln(os.Stderr, "hostname:", m.Hostname)
fmt.Fprintln(os.Stderr, "qid: ", m.QID)
if len(m.Auth) > 0 {
fmt.Fprintln(os.Stderr, "auth: ", m.Auth)
} else {
fmt.Fprintln(os.Stderr, "auth: not authenticated")
}
fmt.Fprintln(os.Stderr, "seen: ", m.seen)
fmt.Fprintln(os.Stderr, "")
}
func (m *Message) RewriteSubject(headers []string, subject string) (err error) {
sQid := strconv.Itoa(m.QID)
filetemp := submitDir + "/" + sQid + "rs.tmp"
filename := submitDir + "/" + sQid + "rs.sub"
fh, err := os.Create(filetemp)
if err != nil {
return fmt.Errorf("RewriteSubject: create tmp failed: %w", err)
}
w := getWriter(fh)
success := false
defer func() {
if !success {
putWriter(w)
fh.Close()
os.Remove(filetemp)
}
}()
// 1. Формируем конверт (Submitted требует Return-Path и Envelope-To)
w.WriteString("Return-Path: ")
w.WriteString(m.From)
w.WriteByte('\n')
for _, r := range m.Rcpts {
w.WriteString("Envelope-To: ")
w.WriteString(r)
w.WriteByte('\n')
}
// 2. Метаданные (Seen, Junk-Score, DKIM).
for _, h := range headers {
w.WriteString(h)
w.WriteByte('\n')
}
// 3. Подготовка к чтению оригинальных заголовков
if _, err = m.File.Seek(m.HdrPos, io.SeekStart); err != nil {
return err
}
rd := getReader(m.File)
defer putReader(rd)
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
// 4. Цикл обработки заголовков: копируем всё, заменяя Subject
for {
buf.Reset()
if err = getHeader(rd, buf); err != nil {
if err == io.EOF {
break
}
return err
}
hdr := buf.Bytes()
if isHeaderEnd(hdr) {
w.Write(hdr)
break
}
// Заменяем оригинальный Subject на новый
if bytes.HasPrefix(hdr, bSubjHdr) {
w.Write(bSubjHdr)
w.WriteString(subject)
w.WriteByte('\n')
continue
}
w.Write(hdr)
}
// 5. Перенос тела письма напрямую через io.Copy
if _, err = m.File.Seek(m.BodyPos, io.SeekStart); err != nil {
return err
}
if _, err = io.Copy(w, m.File); err != nil {
return err
}
if err = w.Flush(); err != nil {
return err
}
if err = fh.Close(); err != nil {
return err
}
if err = os.Rename(filetemp, filename); err != nil {
return err
}
success = true
putWriter(w)
return nil
}
func getReader(r io.Reader) *bufio.Reader {
rd := readerPool.Get().(*bufio.Reader)
rd.Reset(r)
return rd
}
func getWriter(w io.Writer) *bufio.Writer {
bw := writerPool.Get().(*bufio.Writer)
bw.Reset(w)
return bw
}
func hashReceived(b []byte) string {
h := xxhash.New()
start := 0
for i := 0; i < len(b); i++ {
// Нормализация: игнорируем пробелы, табы и переносы строк (folding)
if b[i] <= 32 {
if i > start {
h.Write(b[start:i])
}
start = i + 1
}
}
// Дописываем остаток, если он есть
if start < len(b) {
h.Write(b[start:])
}
// Результат — 64-битное число, приводим к hex (16 символов)
return strconv.FormatUint(h.Sum64(), 16)
}
func isExternal(hdr []byte) bool {
// 1. Проверяем полный префикс.
if !bytes.HasPrefix(hdr, bRecvFrom) {
return false
}
// 2. Смещаемся за "Received: from "
fromVal := hdr[len(bRecvFrom):]
if len(fromVal) == 0 {
return false
}
// 3. Быстрый чек на IP в скобках [IP] (SMTP/Web)
if fromVal[0] == '[' {
return true
}
// 4. Ищем границу идентификатора (до первого пробела)
firstSpace := bytes.IndexByte(fromVal, ' ')
if firstSpace == -1 {
firstSpace = len(fromVal)
}
firstWord := fromVal[:firstSpace]
// 5. Проверка на Email (GROUP/RULE).
// Если во входном идентификаторе нет '@', это сетевой хост.
return len(firstWord) > 0 && !bytes.Contains(firstWord, []byte("@"))
}
func isHeaderEnd(hdr []byte) bool {
return len(hdr) == 1 && hdr[0] == '\n' ||
(len(hdr) == 2 && hdr[0] == '\r' && hdr[1] == '\n')
}
/*
внешние источники: SMTP|HTTPU?|AIRSYNC|IMAP|RPOP|XIMSS
S <test@domain.name> AIRSYNC [83.139.170.75]
S <test@domain.name> HTTP [46.72.226.199]
S SMTP [130.193.65.125]
S <test@domain.name> SMTP [2001:67c:418:2020::21]
S RPOP [81.19.77.161]
внутренние источники: ALARM|DSN|GROUP|ICAL|LIST|LSTM|LSTO|MDN|PBX|PIPE|RULE|WEBUSER
S DSN [0.0.0.0]
S GROUP [0.0.0.0]
S LIST [0.0.0.0]
S LSTM [0.0.0.0]
S <test@domain.name> MDN [0.0.0.0]
S PIPE [0.0.0.0]
S <postmaster@domain.name> RULE [0.0.0.0]
*/
func parseSource(seq int, line []byte, msg *Message) {
// Строка вида: S [<auth> ]MODULE [IP]
data := bytes.TrimSpace(line[1:])
if len(data) == 0 {
return
}
var auth string
// 1. Извлекаем <auth>, если он присутствует
if data[0] == '<' {
endAuth := bytes.IndexByte(data, '>')
if endAuth != -1 {
auth = string(data[1:endAuth])
data = bytes.TrimSpace(data[endAuth+1:])
}
}
// 2. Ищем IP в квадратных скобках в конце строки
openBracket := bytes.LastIndexByte(data, '[')
closeBracket := bytes.LastIndexByte(data, ']')
if openBracket == -1 || closeBracket == -1 || closeBracket <= openBracket {
Putline("* ", seq, " [", msg.QID, "]: unknown S-line format: ", utils.Bytes2string(line))
return
}
moduleBytes := bytes.TrimSpace(data[:openBracket])
ipBytes := data[openBracket+1 : closeBracket]
mType, known := sourceModules[utils.Bytes2string(moduleBytes)]
// 3. Заполнение структуры
if known && mType == 2 && bytes.Equal(ipBytes, []byte("0.0.0.0")) {
// Внутренний источник (trusted)
if auth != "" {
msg.Auth = auth
} else {
msg.Auth = string(moduleBytes) + "@trusted"
}
msg.IP = "127.2.4.7"
} else if known && mType == 1 {
// Внешний источник (SMTP, и т.д.)
msg.Auth = auth
msg.IP = string(ipBytes)
msg.Hostname = getHostname(msg.IP)
} else {
Putline("* ", seq, " [", msg.QID, "]: unknown or mismatched module in S-line: ", string(line))
}
}
func putReader(rd *bufio.Reader) {
rd.Reset(nil)
readerPool.Put(rd)
}
func putWriter(wr *bufio.Writer) {
wr.Reset(nil)
writerPool.Put(wr)
}
+206
View File
@@ -0,0 +1,206 @@
package cgp
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// MessageMock помогает собирать тестовые файлы сообщений CGP в байтовый массив
type MessageMock struct {
Envelope []string
Headers []string
Body string
}
func (m *MessageMock) Render() []byte {
var sb strings.Builder
// 1. Конверт
for _, line := range m.Envelope {
sb.WriteString(line)
sb.WriteByte('\n')
}
sb.WriteByte('\n') // Пустая строка - признак конца конверта
// 2. RFC Заголовки
for _, line := range m.Headers {
sb.WriteString(line)
sb.WriteByte('\n')
}
sb.WriteByte('\n') // Пустая строка - признак конца заголовков
// 3. Тело
sb.WriteString(m.Body)
return []byte(sb.String())
}
// createTestFile создает структуру папок Submitted и пишет туда файл сообщения
func createTestFile(t *testing.T, qid int, content []byte) string {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
filename := filepath.Join(subDir, strconv.Itoa(qid)+".msg")
if err := os.WriteFile(filename, content, 0644); err != nil {
t.Fatal(err)
}
return filename
}
func TestNewMessage_Parsing(t *testing.T) {
rawRecv := "Received: from mail.domain.name ([1.2.3.4] verified)"
mock := &MessageMock{
Envelope: []string{
"P <sender@domain.name>",
"R <rcpt1@domain.name>",
"R <rcpt2@domain.name>",
"S SMTP [1.2.3.4]",
},
Headers: []string{
rawRecv,
"Subject: Test",
"From: sender@domain.name",
},
Body: "Hello world!",
}
content := mock.Render()
qid := 10001
fname := createTestFile(t, qid, content)
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatalf("NewMessage failed: %v", err)
}
defer msg.Close()
// Проверка извлечения данных
if msg.QID != qid {
t.Errorf("QID mismatch: got %d, want %d", msg.QID, qid)
}
if msg.From != "sender@domain.name" {
t.Errorf("From mismatch: got %s", msg.From)
}
if len(msg.Rcpts) != 2 {
t.Errorf("Rcpts count mismatch: got %d", len(msg.Rcpts))
}
if msg.IP != "1.2.3.4" {
t.Errorf("IP mismatch: got %s", msg.IP)
}
if msg.Helo != "mail.domain.name" {
t.Errorf("Helo mismatch: got %s", msg.Helo)
}
// Проверка смещений (HdrPos должен указывать на 'R' в 'Received')
expectedHdrPos := int64(strings.Index(string(content), "Received:"))
if msg.HdrPos != expectedHdrPos {
t.Errorf("HdrPos: got %d, want %d", msg.HdrPos, expectedHdrPos)
}
// Проверка смещения тела (BodyPos после пустой строки после заголовков)
expectedBodyPos := int64(strings.Index(string(content), "Hello world!"))
if msg.BodyPos != expectedBodyPos {
t.Errorf("BodyPos: got %d, want %d", msg.BodyPos, expectedBodyPos)
}
}
func TestMessage_RewriteSubject(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
os.MkdirAll(subDir, 0755)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
// Подготовка мока сообщения
rawRecv := "Received: from mail.domain.name ([1.2.3.4] verified)"
mock := &MessageMock{
Envelope: []string{"P <s@domain.name>", "R <r@domain.name>", "S SMTP [1.2.3.4]"},
Headers: []string{
"From: s@domain.name",
rawRecv, // Наш целевой Received для HELO и Seen
"Subject: Old",
},
Body: "Body Content",
}
qid := 20002
// Создаем тестовый файл .msg
fname := createTestFile(t, qid, mock.Render())
// 1. Инициализируем сообщение (должно найти внешний Received и сохранить его)
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatalf("NewMessage failed: %v", err)
}
defer msg.Close()
// 2. Генерируем Seen-заголовок через метод структуры (логика rspamc)
seenHeader := msg.MakeSeen()
if len(seenHeader) == 0 {
t.Fatalf("MakeSeen returned empty string, check if NewMessage saved rawReceived")
}
newSubj := "SPAM: Original"
// Собираем слайс дополнительных заголовков
rspamdHdrs := []string{
"X-Spam-Score: 10.0",
seenHeader, // Добавляем сгенерированный Seen
}
// 3. Выполняем рерайт в директорию Submitted
err = msg.RewriteSubject(rspamdHdrs, newSubj)
if err != nil {
t.Fatalf("RewriteSubject failed: %v", err)
}
// 4. Проверяем результат в .sub файле
resPath := filepath.Join(subDir, "20002rs.sub")
res, err := os.ReadFile(resPath)
if err != nil {
t.Fatalf("Result file %s not found", resPath)
}
sRes := string(res)
// А. Проверка хеша
// Важно: hashReceived должен работать идентично внутри MakeSeen и здесь в тесте
expectedHash := hashReceived([]byte(rawRecv + "\n"))
if !strings.Contains(sRes, "X-Rspamd-Seen: "+expectedHash) {
t.Errorf("Seen header missing or hash mismatch.\nExpected hash: %s\nFull content:\n%s", expectedHash, sRes)
}
// Б. Проверка замены темы
expectedSubjLine := "Subject: " + newSubj
if !strings.Contains(sRes, expectedSubjLine) {
t.Errorf("New subject %q not found in file", expectedSubjLine)
}
// В. Проверка удаления старой темы
if strings.Contains(sRes, "Subject: Old") {
t.Error("Old subject 'Subject: Old' still present in the file!")
}
// Г. Проверка наличия заголовков из слайса
if !strings.Contains(sRes, "X-Spam-Score: 10.0") {
t.Error("Rspamd headers (X-Spam-Score) missing in rewritten file")
}
}
func TestNewMessage_Malformed(t *testing.T) {
t.Run("Unexpected EOF", func(t *testing.T) {
// Обрываем файл прямо в середине конверта
content := []byte("P <sender@domain.name>\nS SMTP [1.2.3.4]")
fname := createTestFile(t, 30003, content)
_, err := NewMessage(1, fname)
if err == nil || !strings.Contains(err.Error(), "unexpected end of envelope") {
t.Errorf("Expected EOF error, got: %v", err)
}
})
}
+95 -76
View File
@@ -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)
}
}
+157
View File
@@ -0,0 +1,157 @@
package cgp
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNotifyTo(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
os.MkdirAll(subDir, 0755)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
// 1. Готовим "донорское" письмо, из которого будем брать заголовки
qid := 999
mock := &MessageMock{
Envelope: []string{"P <s@domain.name>", "R <r@domain.name>", "S SMTP [1.2.3.4]"},
Headers: []string{"From: sender@domain.name", "Subject: Original", "X-Custom: Value"},
Body: "This body should NOT be in notification",
}
fname := createTestFile(t, qid, mock.Render())
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatal(err)
}
defer msg.Close()
// 2. Вызываем NotifyTo
to := []string{"admin@domain.name"}
notifyFrom := "postmaster@domain.name"
desc := "Spam policy violation detected"
header := "X-Rspamd-Case: 12345"
NotifyTo(1, msg, to, header, notifyFrom, desc)
// 3. Проверяем результат
resPath := filepath.Join(subDir, "999no.sub")
res, err := os.ReadFile(resPath)
if err != nil {
t.Fatalf("Notification file not created: %v", err)
}
sRes := string(res)
// ПРОВЕРКИ:
// А. Конверт (NotifyTo пишет Return-Path: <>)
if !strings.HasPrefix(sRes, "Return-Path: <>\nEnvelope-To: <admin@domain.name>") {
t.Error("Invalid envelope in notification")
}
// Б. MIME Boundary
boundaryLine := "boundary=\"nextPart999.1\""
if !strings.Contains(sRes, boundaryLine) {
t.Errorf("Boundary definition not found. Expected: %s", boundaryLine)
}
// В. Текстовое описание
if !strings.Contains(sRes, desc) || !strings.Contains(sRes, "mail id: 999") {
t.Error("Description or Mail ID missing in notification body")
}
// Г. Аттачмент (Заголовки)
// Проверяем, что заголовки из оригинала попали в аттачмент
if !strings.Contains(sRes, "attachment; filename=\"headers\"") {
t.Error("Attachment header missing")
}
if !strings.Contains(sRes, "X-Custom: Value") {
t.Error("Original headers missing in attachment")
}
// Д. Отсутствие тела оригинала (Проверка SectionReader)
if strings.Contains(sRes, "This body should NOT be in notification") {
t.Error("Original body leaked into notification headers attachment")
}
// Е. Закрывающий boundary
finalBoundary := "--nextPart999.1--"
if !strings.Contains(sRes, finalBoundary) {
t.Error("Final MIME boundary missing")
}
}
func TestNotifyTo_MultipleRcpts(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, submitDir)
os.MkdirAll(subDir, 0755)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
// Создаем сообщение с двумя получателями в конверте
mock := &MessageMock{
Envelope: []string{
"P <s@domain.name>",
"R <r1@domain.name>",
"R <r2@domain.name>",
"S SMTP [1.1.1.1]",
},
Headers: []string{"Subject: Test"},
Body: "Body",
}
qid := 123
fname := createTestFile(t, qid, mock.Render())
msg, err := NewMessage(1, fname)
if err != nil {
t.Fatalf("Failed to create message: %v", err)
}
defer msg.Close()
// Получатели уведомления
to := []string{"admin1@domain.name", "admin2@domain.name"}
// Вызываем генерацию уведомления
NotifyTo(1, msg, to, "X-Rspamd-Scan: 1", "postmaster@domain.name", "Policy violation")
// Читаем результат
resPath := filepath.Join(subDir, "123no.sub")
res, err := os.ReadFile(resPath)
if err != nil {
t.Fatalf("Notification file not found: %v", err)
}
sRes := string(res)
// 1. Проверка Envelope-To (оба адреса должны быть в заголовках конверта)
if !strings.Contains(sRes, "Envelope-To: <admin1@domain.name>") ||
!strings.Contains(sRes, "Envelope-To: <admin2@domain.name>") {
t.Error("Not all notification recipients found in Envelope-To")
}
// 2. Проверка форматирования списка получателей в текстовой части.
// Судя по вашему дампу:
// "rcpt to: r1@domain.name" -> 3 пробела после двоеточия
// "\n r2@domain.name" -> 11 пробелов в начале строки
expectedFirst := "rcpt to: r1@domain.name"
expectedSecond := "\n r2@domain.name"
if !strings.Contains(sRes, expectedFirst) {
t.Errorf("First recipient line mismatch.\nWant: %q\nGot around: %q", expectedFirst, sRes[strings.Index(sRes, "rcpt to:"):strings.Index(sRes, "rcpt to:")+30])
}
if !strings.Contains(sRes, expectedSecond) {
t.Errorf("Indentation for second recipient mismatch.\nWant: %q", expectedSecond)
}
// 3. Проверка mail from (также со скобками)
if !strings.Contains(sRes, "mail from: s@domain.name") {
t.Error("Mail from line mismatch")
}
}
+90 -41
View File
@@ -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
}
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestConfig_New(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.yml")
// Готовим тестовый конфиг.
// YAML теперь парсится через UnmarshalYAML в наш тип Direction.
yamlContent := `
authservid: "test.domain.name"
notifyfrom: "postmaster@test.domain.name"
host: "127.0.0.1:11333"
actions:
reject:
description: "Spam rejected"
direction: "in"
notifyto: ["admin@domain.name"]
`
if err := os.WriteFile(configFile, []byte(yamlContent), 0644); err != nil {
t.Fatal(err)
}
t.Run("Full initialization", func(t *testing.T) {
args := []string{"-config", configFile, "-debug"}
cfg, err := New(args)
if err != nil {
t.Fatalf("New() failed: %v", err)
}
if cfg.Timeout != 15*time.Second {
t.Errorf("Expected default timeout 15s, got %v", cfg.Timeout)
}
if cfg.AuthservId != "test.domain.name" {
t.Errorf("AuthservId mismatch: %s", cfg.AuthservId)
}
if !cfg.debug {
t.Error("Debug flag was not set")
}
expectedHost := "http://127.0.0.1:11333/checkv2"
if cfg.Host != expectedHost {
t.Errorf("Host transformation failed. Got %s, want %s", cfg.Host, expectedHost)
}
})
t.Run("Direction enum parsing", func(t *testing.T) {
args := []string{"-config", configFile}
cfg, _ := New(args)
op, ok := cfg.Actions["reject"]
// Проверяем соответствие константе DirIn (1)
if !ok || op.Direction != DirIn {
t.Errorf("Action 'reject' should have direction DirIn (1), got %v", op.Direction)
}
// Проверка строкового представления
if op.Direction.String() != "in" {
t.Errorf("String representation failed. Got %s, want 'in'", op.Direction.String())
}
})
t.Run("Invalid direction in YAML", func(t *testing.T) {
badHdr := "notifyfrom: \"a@b.c\"\nactions:\n bad:\n direction: \"upwards\""
badFile := filepath.Join(tmpDir, "bad_config.yml")
os.WriteFile(badFile, []byte(badHdr), 0644)
_, err := New([]string{"-config", badFile})
if err == nil {
t.Error("Expected error for invalid direction 'upwards', but got nil")
} else if !strings.Contains(err.Error(), "invalid direction") {
t.Errorf("Unexpected error message: %v", err)
}
})
}
func TestValidateConfig(t *testing.T) {
tests := []struct {
name string
cfg *Config
wantErr bool
}{
{
name: "Valid config",
cfg: &Config{
NotifyFrom: "scanner@domain.name",
Actions: map[string]*Operation{
"quarantine": {Direction: DirBoth},
},
},
wantErr: false,
},
{
name: "Invalid email",
cfg: &Config{
NotifyFrom: "not-an-email",
},
wantErr: true,
},
{
name: "Invalid direction enum",
cfg: &Config{
NotifyFrom: "ok@domain.name",
Actions: map[string]*Operation{
"test": {Direction: Direction(99)}, // Несуществующее значение
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("validateConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestVersionOutput(t *testing.T) {
Version = "1.0.0"
Commit = "abcde"
out := GetVersion()
if !contains(out, "1.0.0") || !contains(out, "abcde") {
t.Errorf("Version output mismatch: %s", out)
}
}
func TestConfig_AuthservIdFallback(t *testing.T) {
tmpDir := t.TempDir()
settingsDir := filepath.Join(tmpDir, "Settings")
os.MkdirAll(settingsDir, 0755)
mainSettingsContent := []byte(`DomainName = "domain.name";`)
os.WriteFile(filepath.Join(settingsDir, "Main.settings"), mainSettingsContent, 0644)
configFile := filepath.Join(tmpDir, "config.yml")
yamlContent := []byte("notifyfrom: \"postmaster@domain.name\"\nhost: \"localhost:11333\"")
os.WriteFile(configFile, yamlContent, 0644)
oldWd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldWd)
t.Run("Detect AuthservId from Main.settings", func(t *testing.T) {
args := []string{"-config", configFile}
cfg, err := New(args)
if err != nil {
t.Fatalf("New() failed to detect domain: %v", err)
}
if cfg.AuthservId != "domain.name" {
t.Errorf("Expected AuthservId 'domain.name', got %q", cfg.AuthservId)
}
})
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
+51
View File
@@ -0,0 +1,51 @@
package config
import (
"fmt"
)
type Direction int
const (
DirBoth Direction = iota
DirIn
DirOut
)
// MarshalYAML позволяет Marshal печатать строку вместо числа
func (d Direction) MarshalYAML() (any, error) {
return d.String(), nil
}
// String реализует интерфейс fmt.Stringer
func (d Direction) String() string {
switch d {
case DirIn:
return "in"
case DirOut:
return "out"
case DirBoth:
return "both"
default:
return fmt.Sprintf("unknown(%d)", d)
}
}
// UnmarshalYAML позволяет прозрачно читать "in", "out", "both" из конфига
func (d *Direction) UnmarshalYAML(unmarshal func(any) error) error {
var s string
if err := unmarshal(&s); err != nil {
return err
}
switch s {
case "in":
*d = DirIn
case "out":
*d = DirOut
case "both", "": // Пустое значение трактуем как both
*d = DirBoth
default:
return fmt.Errorf("invalid direction: %s (want: in, out, both)", s)
}
return nil
}
+13 -34
View File
@@ -9,42 +9,31 @@ import (
var reMail *regexp.Regexp
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
}
+4 -6
View File
@@ -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
)
+6 -15
View File
@@ -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=
+38 -19
View File
@@ -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
View File
@@ -0,0 +1,221 @@
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"git.vsu.ru/ai/rspamd-cgp/config"
"git.vsu.ru/ai/rspamd-cgp/rspamc"
)
func TestIntegration_FullMatrix(t *testing.T) {
// 1. Подготовка бинарника
_, filename, _, _ := runtime.Caller(0)
testSrcDir := filepath.Dir(filename)
helperPath, _ := filepath.Abs(filepath.Join(testSrcDir, "rspamd-cgp"))
if _, err := os.Stat(helperPath); os.IsNotExist(err) {
t.Fatalf("Helper binary not found. Build it: go build -o rspamd-cgp")
}
type testCase struct {
name string
isOutbound bool
rspamdResp string
configYaml string
expect []string // Ожидаемые подстроки в ответе хелпера
checkFile bool // Нужно ли проверять наличие .sub файла
}
matrix := []testCase{
{
name: "1. IN: Clean Message",
rspamdResp: `{"action":"no action","score":0.1,"symbols":{"ALL_OK":{"score":0}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\n",
expect: []string{"2 OK"},
},
{
name: "2. IN: Virus -> Mirror + Discard",
rspamdResp: `{"action":"no action","symbols":{"VIRUS":{"score":10}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\nsymbols:\n VIRUS: { discard: true, mirrorto: [\"quarantine@domain.name\"] }\n",
expect: []string{"MIRRORTO", "quarantine@domain.name", "DISCARD"},
},
{
name: "3. IN: Spam -> Rewrite Subject",
rspamdResp: `{"action":"rewrite subject","subject":"[SPAM] Test","score":8,"symbols":{"SPAM_LOW":{"score":5}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\nsymbols:\n SPAM_LOW: { Discard: true }\n",
expect: []string{"2 ADDHEADER", "DISCARD"},
checkFile: true,
},
{
name: "4. IN: High Score -> Reject Action (Junk-Score)",
rspamdResp: `{"action":"reject","score":15}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\n",
expect: []string{"ADDHEADER", "X-Junk-Score: [XXXXXXXXXX]", "OK"},
},
{
name: "5. OUT: Virus -> Discard (No Mirror for outbound)",
isOutbound: true,
rspamdResp: `{"action":"no action","symbols":{"VIRUS":{"score":10}}}`,
configYaml: "authservid: \"domain.name\"\nhost: \"%s\"\nnotifyfrom: \"p@v.ru\"\nsymbols:\n VIRUS: { discard: true, mirrorto: [\"quarantine@domain.name\"] }\n",
expect: []string{"DISCARD"},
},
}
for _, tc := range matrix {
t.Run(tc.name, func(t *testing.T) {
// Создаем песочницу
tmpDir := t.TempDir()
os.MkdirAll(filepath.Join(tmpDir, "Queue"), 0777)
os.MkdirAll(filepath.Join(tmpDir, "Submitted"), 0777)
// Поднимаем эмулятор Rspamd
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, tc.rspamdResp)
}))
defer ts.Close()
// Пишем конфиг
confPath := filepath.Join(tmpDir, "config.yml")
os.WriteFile(confPath, []byte(fmt.Sprintf(tc.configYaml, strings.TrimPrefix(ts.URL, "http://"))), 0644)
// Создаем письмо
qid := "999"
msgPath := filepath.Join(tmpDir, "Queue", qid+".msg")
msgData := "S <s@v.ru> SMTP [1.1.1.1]\nP <s@v.ru>\nR <r@v.ru>\n\n" +
"Received: from x by domain.name; Thu, 05 Mar 2026 12:00:00 +0300\n" +
"From: s@v.ru\nSubject: Test\n\nBody content"
os.WriteFile(msgPath, []byte(msgData), 0644)
// Запуск хелпера
args := []string{"-config", confPath}
if tc.isOutbound {
args = append(args, "-outbound")
}
cmd := exec.Command(helperPath, args...)
cmd.Dir = tmpDir
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
stderrReader, _ := cmd.StderrPipe()
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
// Читаем stderr, чтобы не блокировать процесс
go io.Copy(io.Discard, stderrReader)
// Читаем результат
resChan := make(chan string)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if len(line) > 0 && line[0] >= '0' && line[0] <= '9' && !strings.Contains(line, "INTF") {
resChan <- line
return
}
}
}()
// Команды протокола
fmt.Fprint(stdin, "1 INTF 4\n")
fmt.Fprintf(stdin, "2 FILE Queue/%s.msg\n", qid)
select {
case res := <-resChan:
for _, exp := range tc.expect {
if !strings.Contains(res, exp) {
t.Errorf("[%s] Expected '%s', got: %s", tc.name, exp, res)
}
}
case <-time.After(3 * time.Second):
cmd.Process.Kill()
t.Fatalf("[%s] Timeout waiting for response", tc.name)
}
// Проверка файла в Submitted
if tc.checkFile {
subPath := filepath.Join(tmpDir, "Submitted", qid+"rs.sub")
if _, err := os.Stat(subPath); os.IsNotExist(err) {
t.Errorf("[%s] File %s not created!", tc.name, subPath)
}
}
fmt.Fprint(stdin, "3 QUIT\n")
stdin.Close()
cmd.Wait()
})
}
}
func createCgpCompliantFile(t *testing.T, qid int) string {
path := filepath.Join(os.TempDir(), fmt.Sprintf("%d.msg", qid))
var buf bytes.Buffer
// 1. Формат CGP Envelope
buf.WriteString("S <test@domain.name> SMTP [2001:67c:418:2020::21]\n")
buf.WriteString(fmt.Sprintf("P <%d@domain.name>\n", qid))
buf.WriteString("R <rcpt@domain.name>\n")
buf.WriteString("\n") // Конец Envelope
// 2. Тело письма (RFC5322)
buf.WriteString("Received: from mail.domain.name ([1.2.3.4] verified)\n")
buf.WriteString("From: <sender@domain.name>\n")
buf.WriteString("Subject: Bench\n")
buf.WriteString("\n")
buf.WriteString("Body content goes here")
os.WriteFile(path, buf.Bytes(), 0644)
return path
}
func BenchmarkRspamc_Scan_RealWork(b *testing.B) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"action": "no action", "score": -2.75, "symbols": {"RCPTS_DOMAINS_LOCAL": {"options": ["domain.name"]}}}`)
}))
defer ts.Close()
// Создаем правильный файл
tmpMsg := createCgpCompliantFile(nil, 555)
defer os.Remove(tmpMsg)
// Глушим syscall.Write
null, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
oldStdout, _ := syscall.Dup(1)
syscall.Dup2(int(null.Fd()), 1)
oldArgs := os.Args
os.Args = []string{"rspamd-cgp"}
defer func() { os.Args = oldArgs }()
conf, err := config.New(os.Args[1:])
if err != nil {
os.Stderr.WriteString("config: " + err.Error() + "\n")
os.Exit(1)
}
config.SetGlobal(conf)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
rspamc.Scan(i, tmpMsg)
}
syscall.Dup2(oldStdout, 1)
}
+218 -158
View File
@@ -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)
}
}
+103
View File
@@ -0,0 +1,103 @@
package rspamc
import (
"testing"
"git.vsu.ru/ai/rspamd-cgp/config"
)
func TestMakeOpSum_Logic(t *testing.T) {
// Инициализируем конфиг для теста
conf := &config.Config{
Actions: map[string]*config.Operation{
"add header": {Direction: config.DirBoth, Discard: false, NotifyTo: []string{"admin@domain.name"}},
},
Symbols: map[string]*config.Operation{
"VIRUS": {Direction: config.DirBoth, Discard: true, MirrorTo: []string{"quarantine@domain.name"}},
"SPAM": {Direction: config.DirIn, Discard: false, MirrorTo: []string{"spam-box@domain.name"}},
},
}
config.SetGlobal(conf)
t.Run("Merge Action and Virus Symbol", func(t *testing.T) {
res := &RspamdResponse{
Action: "add header",
Symbols: map[string]*Symbol{
"VIRUS": {Score: 10},
},
}
// По умолчанию работаем как Inbound (config.outbound = false)
opsum, cases, _, _ := makeOpSum(res, "add header")
if opsum == nil {
t.Fatal("Expected opsum, got nil")
}
if !opsum.Discard {
t.Error("Discard must be TRUE because of VIRUS symbol")
}
if len(opsum.MirrorTo) != 1 || opsum.MirrorTo[0] != "quarantine@domain.name" {
t.Errorf("MirrorTo mismatch: %v", opsum.MirrorTo)
}
if cases != "add header,VIRUS" {
t.Errorf("Cases mismatch: %s", cases)
}
})
t.Run("Direction awareness (Manual Check)", func(t *testing.T) {
// Для этого теста проверим логику фильтрации напрямую через isOpAccept,
// так как подменить глобальное состояние outbound без рефлексии или
// пересоздания конфига через New() сложно.
tests := []struct {
name string
dir config.Direction
outbound bool
want bool
}{
{"Inbound: In accept", config.DirIn, false, true},
{"Inbound: Out reject", config.DirOut, false, false},
{"Inbound: Both accept", config.DirBoth, false, true},
{"Outbound: In reject", config.DirIn, true, false},
{"Outbound: Out accept", config.DirOut, true, true},
{"Outbound: Both accept", config.DirBoth, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isOpAccept(tt.dir, tt.outbound); got != tt.want {
t.Errorf("isOpAccept(%v, %v) = %v; want %v", tt.dir, tt.outbound, got, tt.want)
}
})
}
})
}
func TestFilterLocalRcpts_Scenarios(t *testing.T) {
res := &RspamdResponse{
Symbols: map[string]*Symbol{
"RCPTS_DOMAINS_LOCAL": {
Options: []string{"domain.name"},
},
},
}
tests := []struct {
name string
input []string
expected int
}{
{"All Local", []string{"<a@domain.name>", "<b@domain.name>"}, 2},
{"Mixed", []string{"<a@domain.name>", "<external@gmail.com>"}, 1},
{"None Local", []string{"<hacker@evil.com>"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := filterLocalRcpts(tt.input, res)
if len(out) != tt.expected {
t.Errorf("Got %d, want %d for %s", len(out), tt.expected, tt.name)
}
})
}
}
+99 -105
View File
@@ -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)
}
+83
View File
@@ -0,0 +1,83 @@
package rspamc
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.vsu.ru/ai/rspamd-cgp/config"
)
func TestScan_HttpRequestConstruction(t *testing.T) {
// Создаем тестовое сообщение
tmpDir := t.TempDir()
msgPath := filepath.Join(tmpDir, "100.msg")
// Важно: HdrPos будет 24 (после \n\n)
os.WriteFile(msgPath, []byte("P <s@domain.name>\nR <r@domain.name>\n\nFrom: s@domain.name\n\nBody"), 0644)
// Проверяем, что заголовки из CGP Message долетели до Rspamd
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CommuniGate хранит адреса в скобках, проверяем именно этот вариант
expectedFrom := "s@domain.name"
if r.Header.Get("From") != expectedFrom {
t.Errorf("Expected From header %s, got %s", expectedFrom, r.Header.Get("From"))
}
if r.Header.Get("MTA-Tag") != "test-mta" {
t.Errorf("Expected MTA-Tag, got %s", r.Header.Get("MTA-Tag"))
}
// Возвращаем мок-ответ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(RspamdResponse{
Action: "no action",
Score: 0.1,
})
}))
defer ts.Close()
// Настраиваем конфиг на этот сервер
conf := &config.Config{
Host: ts.URL,
AuthservId: "test-mta",
NotifyFrom: "postmaster@domain.name",
}
config.SetGlobal(conf)
// Запускаем скан.
// Мы не проверяем stdout (это требует сложного перехвата),
// но убеждаемся, что цепочка вызовов проходит без ошибок и паник.
Scan(1, msgPath)
}
func TestRspamdResponse_Unmarshalling(t *testing.T) {
// Проверяем, что наша структура корректно ест сложный JSON от Rspamd
rawJSON := `{
"action": "add header",
"score": 5.5,
"required_score": 7.0,
"milter": {
"add_headers": {
"X-Spam": {"value": "Yes"}
}
},
"symbols": {
"TEST_SYM": {"score": 1.2, "description": "some sym"}
}
}`
var res RspamdResponse
err := json.Unmarshal([]byte(rawJSON), &res)
if err != nil {
t.Fatalf("Unmarshall failed: %v", err)
}
if res.Milter.AddHeaders["X-Spam"].Value != "Yes" {
t.Error("Failed to parse milter headers")
}
if res.Symbols["TEST_SYM"].Score != 1.2 {
t.Error("Failed to parse symbols")
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
# Устанавливает значение authserv-id в заголовке Authentication-Results, RFC7001
# Если не задан, ему присваивается имя главного домена 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
View File
@@ -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 {
+62
View File
@@ -0,0 +1,62 @@
package utils
import (
"testing"
)
func TestBytes2string(t *testing.T) {
t.Run("Valid conversion", func(t *testing.T) {
input := []byte("hello world")
got := Bytes2string(input)
if got != "hello world" {
t.Errorf("Expected 'hello world', got %q", got)
}
})
t.Run("Empty slice", func(t *testing.T) {
if got := Bytes2string([]byte{}); got != "" {
t.Errorf("Expected empty string, got %q", got)
}
if got := Bytes2string(nil); got != "" {
t.Errorf("Expected empty string for nil, got %q", got)
}
})
}
func TestUniqueSliceElementsNonEmpty(t *testing.T) {
tests := []struct {
name string
input []string
want []string
}{
{
name: "Duplicates and empty strings",
input: []string{"a", "b", "", "a", "c", "b", " "},
want: []string{"a", "b", "c", " "}, // Пробел не пустая строка
},
{
name: "All empty",
input: []string{"", "", ""},
want: []string{},
},
{
name: "Already unique",
input: []string{"one", "two"},
want: []string{"one", "two"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := UniqueSliceElementsNonEmpty(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("Length mismatch: got %d, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("At index %d: got %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}