Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 185637b990 | |||
| 7ba94253e3 | |||
| be1849681d | |||
| ee953c1b60 | |||
| d5b7405971 | |||
| 0cad04da66 | |||
| 7157abd56c | |||
| f00cfd4153 | |||
| b823f4f0a3 | |||
| 02d6e8fa06 | |||
| 6c86bbf6b0 | |||
| 1d9e6b5b33 | |||
| 9401266553 | |||
| 3c5aef25e9 | |||
| ebb759cb30 | |||
| fd76bb19f8 | |||
| 8127c512ce | |||
| 8373f81f24 | |||
| fcaa4b1a9d | |||
| 53ec580c99 | |||
| 6f36f283e2 | |||
| ccb1ccc20e | |||
| ac6fdff1dd | |||
| 0816768341 | |||
| 2add0e8caa | |||
| a83623e6da | |||
| 4cf590ef5f | |||
| 37fa8a3d09 | |||
| 6686a722b8 | |||
| 9ea6e3a535 | |||
| 938ff7d8c9 | |||
| d3c34f4414 | |||
| f5d60164be | |||
| ca99deb5e1 | |||
| b074a371f5 | |||
| 40ba8f6878 | |||
| 2b8d64c89e | |||
| 659d1bf531 | |||
| 9a4226ceea | |||
| aed98832e4 | |||
| ae3586e0cc | |||
| 3ef6db6a7d | |||
| c5a5acbd4f | |||
| 489783d652 | |||
| 7462a3de80 | |||
| e0d11b742f | |||
| f896c4ad0e | |||
| eabe7b6eb0 | |||
| 3ebf21580a | |||
| c0550a18fe | |||
| f23bd04dbe | |||
| 768cfd4e61 | |||
| 265eacb356 | |||
| 27d5096d56 | |||
| a5bb18126d | |||
| 1bcab2359d | |||
| 23e91eaee7 | |||
| 82618ee86d | |||
| 18bcfbe69e | |||
| bb4e1a6fb8 | |||
| 88dce405fc | |||
| eec1effcb2 | |||
| 4aec643190 | |||
| 7b5ee82552 | |||
| 6711b3e93f | |||
| 36e44058f8 | |||
| fc83af2612 | |||
| 12be6f9542 | |||
| cbf3d3c3ea | |||
| 7e6092c4b1 | |||
| 12fbcdd400 | |||
| b6c8138cc5 | |||
| a46f66a090 | |||
| 1bb820fee0 | |||
| a55162d803 | |||
| f70e2f46e1 | |||
| 6e49551391 | |||
| 2fc0afacac | |||
| 76428fc6ff | |||
| 3403b432af | |||
| 13a08b3725 | |||
| 4ed58b477b | |||
| 5b302def1b | |||
| b4c6d972de | |||
| a2c7940ba1 | |||
| 9ade57dc0c | |||
| fe3d70f2fb | |||
| 229929ee38 | |||
| 2c26e3ec35 | |||
| d1aafd582b | |||
| 0e3bea28ff | |||
| fb55c39e0b | |||
| 49ac3dfe43 | |||
| d14a95d49e | |||
| c6772637a6 | |||
| ca22973c25 | |||
| db2f22658a | |||
| efa8212eab | |||
| dc48f83f7e | |||
| 88a9cbcb1c | |||
| 8fac3092f2 | |||
| 5e25542fec | |||
| 281b3a84a5 | |||
| 2dbf407442 | |||
| ec85ae1684 | |||
| a13c4b63f0 | |||
| 2f5fc86d3d | |||
| 008f82f0f4 | |||
| 65a41c26db | |||
| 9384b91fb7 | |||
| ce3894864a | |||
| ed96f0c66f | |||
| aae34c04f4 | |||
| d0f6547da9 | |||
| 1987653574 | |||
| eb9d4943e8 | |||
| 5bcfec0eaa | |||
| aa8f9a4987 | |||
| 6f9ef2c770 | |||
| 41d9f0df73 | |||
| 279bdd003b | |||
| 5f6631176d | |||
| 21df0d8fd7 | |||
| 3c41283fed | |||
| 1b0b9d7604 | |||
| fd72237063 |
@@ -1,26 +0,0 @@
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
3. The name of the author may not be used to endorse or promote
|
||||
products derived from this software without specific prior written
|
||||
permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
|
||||
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
|
||||
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2021-2026, Andrey Igoshin <ai@vsu.ru>
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,24 +1,111 @@
|
||||
[](#) [](README.ru.md)
|
||||
|
||||
Rspamd plugin for CommuniGate Pro 5.x, 6.x
|
||||
## Rspamd helper for CommuniGate Pro 6.x, *version 3.0.0*
|
||||
|
||||
Installation and usage instructions can be found at
|
||||
http://www.communigate.com/CGPMcAfee/#Integrate
|
||||
### Introduction
|
||||
|
||||
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/>
|
||||
|
||||
Copyright (C) 2017-2022 Andrey Igoshin <ai@vsu.ru>
|
||||
Version 1.4.0
|
||||
### Features
|
||||
|
||||
https://git.vsu.ru/ai/rspamd-cgp
|
||||
* **High Performance:** Starting with version 3.0.0, the *Helper* utilizes *streaming* data processing. This ensures near-constant memory consumption and a significant reduction in allocations, regardless of the processed message size.
|
||||
|
||||
* The *Helper* receives messages from *CommuniGate Pro* via the *External Filter Interface* protocol and transmits them to *Rspamd* using the *Rspamd protocol*.
|
||||
|
||||
* If a message is received from an authenticated source, the *Helper* passes the `Auth:` header in the *Rspamd protocol*.
|
||||
|
||||
* The *Helper* determines the SMTP `HELO/EHLO` identity based on the first `Received:` header of the message and passes the `Helo:` header in the *Rspamd protocol*.
|
||||
|
||||
* The *Helper* performs reverse DNS lookup for the message source IP address. If successful, it passes the `Hostname:` header in the *Rspamd protocol*. Lookup results are cached.
|
||||
|
||||
* The *Helper* distinguishes between messages received from external sources and those generated by *CommuniGate Pro*. When passing generated messages to *Rspamd*, the *Helper* marks them as originating from a trusted source.
|
||||
|
||||
* The *Helper* generates the `X-Junk-Score:` header based on the *action* returned by *Rspamd*. This header is processed by the built-in *Spam Management* rules configured in the *CommuniGate Pro* user interface.
|
||||
|
||||
* The *Helper* adds all headers received from *Rspamd* to the message. The decision to include these headers is made on the *Rspamd* side.
|
||||
|
||||
* Upon receiving an `action: rewrite subject`, the *Helper* rewrites the message `Subject:` as specified in the *Rspamd* response. This operation triggers the message to pass through the *CommuniGate Pro PIPE*.
|
||||
|
||||
* In certain scenarios, to prevent double processing or message looping, the *Helper* adds an `X-Rspamd-Seen:` header to the processed message.
|
||||
|
||||
* The *Helper* can perform additional message processing based on its configuration file using data from the *Rspamd* response. Additional processing can be applied to both *actions* and *symbols*. If multiple *actions* and *symbols* match in the configuration file for a specific message, the results are aggregated.
|
||||
|
||||
* The *Helper* can process outbound messages. This allows utilizing various *Rspamd* modules to reduce False Positives (FP).
|
||||
|
||||
### Installation
|
||||
|
||||
The *Helper* configuration file [rspamd-cgp.yml](rspamd-cgp.yml) is located by default in the same directory as the **rspamd-cgp** executable. An alternative configuration file path can be specified via the command line. All available settings are described in detail within the configuration file.
|
||||
|
||||
> Below are the *Helper* settings and *Rules* within the *CommuniGate Pro* interface.
|
||||
|
||||
#### For Inbound Messages
|
||||
|
||||
##### Settings -> General -> Helpers
|
||||

|
||||
|
||||
##### Settings -> Mail -> Rules -> RSPAMD_in
|
||||

|
||||
|
||||
#### For Outbound Messages
|
||||
|
||||
##### Settings -> General -> Helpers
|
||||

|
||||
|
||||
##### Settings -> Mail -> Rules -> RSPAMD_out
|
||||

|
||||
|
||||
#### Command Line Arguments
|
||||
|
||||
```
|
||||
Usage of rspamd-cgp:
|
||||
-authserv-id string
|
||||
Authentication Identifier (default CommuniGate Pro Main Domain)
|
||||
-host string
|
||||
Rspamd host to connect (default "localhost:11333")
|
||||
-mirror-to string
|
||||
Mirror selected messages to email
|
||||
-reject-score float
|
||||
Reject score to discard (default +Inf)
|
||||
-timeout duration
|
||||
Rspamd request timeout (default 15s)
|
||||
-config string
|
||||
Set configuration file (default "rspamd-cgp.yml")
|
||||
-configdump
|
||||
Perform configuration file dump
|
||||
-configtest
|
||||
Perform configuration file test
|
||||
-debug
|
||||
Run in debug mode
|
||||
-outbound
|
||||
Outbound message flow processing
|
||||
-version
|
||||
Print version and exit
|
||||
```
|
||||
|
||||
**config**
|
||||
|
||||
Specifies an alternative configuration file.
|
||||
|
||||
**configdump**
|
||||
|
||||
Outputs the configuration file in a formatted view.
|
||||
|
||||
**configtest**
|
||||
|
||||
Verifies the syntactic correctness of the configuration file.
|
||||
|
||||
**debug**
|
||||
|
||||
Outputs the *Rspamd* response (JSON) in a formatted view. Can be used to monitor *symbols* and other data returned by *Rspamd*. The input file must be in the *CommuniGate Pro* queue file format. ***Use only when running from the command line!!!***
|
||||
|
||||
**outbound**
|
||||
|
||||
Processes the outbound message flow. If outbound messages are sent to external MTAs, spam check headers are not added. The specific messages to be processed in this mode are determined by the *CommuniGate Pro Rule*.
|
||||
|
||||
<br/>
|
||||
|
||||
### License
|
||||
|
||||
BSD License, [LICENSE.md](LICENSE.md)<br/><br/>
|
||||
|
||||
### Author
|
||||
|
||||
Andrey Igoshin <<ai@vsu.ru>><br/><br/>
|
||||
|
||||
### Links
|
||||
|
||||
* Repository: <https://git.vsu.ru/ai/rspamd-cgp>
|
||||
* CommuniGate Pro Website: <https://communigatepro.ru>
|
||||
* Helper Protocol: <https://doc.communigatepro.ru/russian/development/Helpers.html#Filters>
|
||||
* Rspamd Website: <https://rspamd.com>
|
||||
* Rspamd Protocol: <https://rspamd.com/doc/developers/protocol.html>
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
[](README.md) [](#)
|
||||
|
||||
## Rspamd helper for CommuniGate Pro 6.x, *версия 3.0.0*
|
||||
|
||||
### Введение
|
||||
|
||||
*Помощник* (внешний фильтр сообщений) **rspamd-cgp** используется для фильтрования спама. Он получает сообщения от *CommuniGate Pro* и передаёт их для обработки в *Rspamd*.<br/><br/>
|
||||
|
||||
### Возможности
|
||||
|
||||
* **Высокая производительность:** Начиная с версии 3.0.0, *Помощник* использует потоковую (*streaming*) обработку данных. Это позволило добиться практически константного потребления памяти и значительного снижения количества аллокаций, независимо от размера обрабатываемого сообщения.
|
||||
|
||||
* *Помощник* получает сообщение от *CommuniGate Pro* по протоколу *Интерфейса Внешнего Фильтра* и передаёт сообщение в *Rspamd*, используя *Rspamd protocol*.
|
||||
|
||||
* Если сообщение получено из аутентифицированного источника, *Помощник* передаёт заголовок *Auth:* в *Rspamd protocol*.
|
||||
|
||||
* *Помощник* определяет *HELO/EHLO* протокола *SMTP* на основании первого заголовка *Received:* сообщения и передаёт заголовок *Helo:* в *Rspamd protocol*.
|
||||
|
||||
* *Помощник* выполняет резольвинг ip-адреса источника сообщения, и, если резольвинг успешен, передаёт заголовок *Hostname:* в *Rspamd protocol*. Результаты резольвинга кешируются.
|
||||
|
||||
* *Помощник* различает сообщения, полученные из внешних источников, и сгенерированные *CommuniGate Pro*. При передаче в *Rspamd* сгенерированных сообщений *Помощник* помечает их как полученные из доверенного источника.
|
||||
|
||||
* *Помощник* формирует заголовок *X-Junk-Score:* на основании *action*, пришедшего от *Rspamd*. Этот заголовок обрабатывается встроенными правилами *Управление Спамом*, которые настраиваются в пользовательском интерфейсе *CommuniGate Pro*.
|
||||
|
||||
* *Помощник* добавляет в сообщение все полученные от *Rspamd* заголовки. Решение о добавлении этих заголовков принимается на стороне *Rspamd*.
|
||||
|
||||
* При получении *action: rewrite subject* *Помощник* переписывает *Subject:* сообщения как указано в ответе *Rspamd*. Эта операция вызывает проход сообщения через *CommuniGate Pro PIPE*.
|
||||
|
||||
* В ряде случаев, чтобы избежать двойной обработки или зацикливания сообщений, *Помощник* добавляет в обработанное сообщение заголовок *X-Rspamd-Seen:*.
|
||||
|
||||
* *Помощник* может выполнять дополнительную обработку сообщений на основании своего конфигурационного файла, используя данные из ответа *Rspamd*. Дополнительная обработка может быть применена к *действиям* и *символам*. Если для конкретного сообщения в конфигурационном файле *Помощника* совпали несколько *действий* и *символов*, для получения итогового результата они суммируются.
|
||||
|
||||
* *Помощник* может обрабатывать исходящие сообщения. Это позволяет задействовать ряд модулей *Rspamd* для уменьшения ложных срабатываний (FP).
|
||||
|
||||
|
||||
### Установка
|
||||
|
||||
Конфигурационный файл *Помощника* [rspamd-cgp.yml](rspamd-cgp.yml) по умолчанию находится в той же директории, что и исполняемый файл **rspamd-cgp**. При необходимости другое местоположение конфигурационного файла можно указать в командной строке. Возможные настройки *Помощника* подробно описаны в конфигурационном файле.
|
||||
|
||||
> Ниже показаны настройки *Помощника* и *Правила* в интерфейсе *CommuniGate Pro*.
|
||||
|
||||
#### Для входящих сообщений
|
||||
|
||||
##### Установки -> Общее -> Помощники
|
||||

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

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

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

|
||||
|
||||
#### Параметры командной строки
|
||||
|
||||
```
|
||||
Usage of rspamd-cgp:
|
||||
-config string
|
||||
Set configuration file (default "rspamd-cgp.yml")
|
||||
-configdump
|
||||
Perform configuration file dump
|
||||
-configtest
|
||||
Perform configuration file test
|
||||
-debug
|
||||
Run in debug mode
|
||||
-outbound
|
||||
Outbound message flow processing
|
||||
-version
|
||||
Print version and exit
|
||||
```
|
||||
|
||||
**config**
|
||||
|
||||
Указывает альтернативный конфигурационный файл.
|
||||
|
||||
**configdump**
|
||||
|
||||
Выводит конфигурационный файл в форматированном виде.
|
||||
|
||||
**configtest**
|
||||
|
||||
Проверяет синтаксическую корректность конфигурационного файла.
|
||||
|
||||
**debug**
|
||||
|
||||
Выводит в форматированном виде ответ *Rspamd* (JSON). Может быть использован для контроля возвращаемых *Rspamd* *символов* и других данных. Входной файл должен быть в формате файла очереди *CommuniGate Pro*. ***Применять только при запуске из командной строки!!!***
|
||||
|
||||
**outbound**
|
||||
|
||||
Обрабатывает поток исходящих сообщений. Если исходящие сообщения отправляются на внешние MTA, в них не добавляются заголовки, являющиеся результатом проверки на спам. Какие именно сообщения обрабатываются таким образом определяется *Правилом* *CommuniGate Pro*.
|
||||
|
||||
<br/>
|
||||
|
||||
### Лицензия
|
||||
|
||||
BSD License, [LICENSE.md](LICENSE.md)<br/><br/>
|
||||
|
||||
### Автор
|
||||
|
||||
Andrey Igoshin <<ai@vsu.ru>><br/><br/>
|
||||
|
||||
### Ссылки
|
||||
|
||||
* Репозиторий: <https://git.vsu.ru/ai/rspamd-cgp>
|
||||
* Сайт CommuniGate Pro: <https://communigatepro.ru>
|
||||
* Протокол Помощника: <https://doc.communigatepro.ru/russian/development/Helpers.html#Filters>
|
||||
* Сайт Rspamd: <https://rspamd.com>
|
||||
* Протокол Rspamd: <https://rspamd.com/doc/developers/protocol.html>
|
||||
@@ -2,13 +2,50 @@
|
||||
|
||||
export GOPATH="${HOME}/src/rspamd-cgp"
|
||||
|
||||
if [ "$1" == "fmt" ]; then
|
||||
go fmt $*
|
||||
elif [ "$1" == "tidy" ]; then
|
||||
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
|
||||
|
||||
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
|
||||
elif [ "$1" == "vet" ]; then
|
||||
echo "vet..."
|
||||
go vet
|
||||
else
|
||||
go build
|
||||
fi
|
||||
;;
|
||||
"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
|
||||
|
||||
+239
-176
@@ -2,243 +2,306 @@ package cgp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var MainDomain string
|
||||
var reMD *regexp.Regexp
|
||||
var reSELF *regexp.Regexp
|
||||
var reSMTP *regexp.Regexp
|
||||
var protocol int
|
||||
type Appender interface {
|
||||
Append([]byte) []byte
|
||||
}
|
||||
|
||||
func init() {
|
||||
reMD = regexp.MustCompile(`^\s+DomainName\s+=\s+([^;]+);`)
|
||||
reSELF = regexp.MustCompile(`^S (?:<([^>]+)> )?(?:DSN|GROUP|LIST|PBX|PIPE|RULE) \[0\.0\.0\.0\]`)
|
||||
reSMTP = regexp.MustCompile(`^S (?:<([^>]+)> )?(?:SMTP|HTTPU?|AIRSYNC|XIMSS) \[([0-9a-f.:]+)\]`)
|
||||
const (
|
||||
submitDir = "Submitted"
|
||||
)
|
||||
|
||||
err := setMainDomain()
|
||||
if err != nil {
|
||||
Putline("* Can not detect Main Domain: %v\n", err)
|
||||
}
|
||||
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 Discard(seq int) {
|
||||
Putline("%d DISCARD\n", seq)
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " DISCARD\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
func Failure(seq, qid int, err error) {
|
||||
Putline("* %d [%d]: %s\n", seq, qid, err)
|
||||
Putline("%d FAILURE\n", seq)
|
||||
var buf [512]byte
|
||||
b := buf[:0]
|
||||
|
||||
b = append(b, "* "...)
|
||||
b = strconv.AppendInt(b, int64(seq), 10)
|
||||
b = append(b, " ["...)
|
||||
b = strconv.AppendInt(b, int64(qid), 10)
|
||||
b = append(b, "]: "...)
|
||||
if err != nil {
|
||||
b = append(b, err.Error()...)
|
||||
} else {
|
||||
b = append(b, "unknown error"...)
|
||||
}
|
||||
|
||||
length := len(b)
|
||||
if length > 0 && b[length-1] != '\n' {
|
||||
b = append(b, '\n')
|
||||
}
|
||||
syscall.Write(stdoutFd, b)
|
||||
|
||||
b = buf[:0]
|
||||
b = strconv.AppendInt(b, int64(seq), 10)
|
||||
b = append(b, " FAILURE\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
// InitStdoutFd инициализирует системный дескриптор для ответов серверу.
|
||||
// Вызывать в самом начале main().
|
||||
func InitStdoutFd() error {
|
||||
rawConn, err := os.Stdout.SyscallConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = rawConn.Control(func(fd uintptr) {
|
||||
stdoutFd = int(fd)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Intf(seq int, ver string) {
|
||||
protocol, _ = strconv.Atoi(ver)
|
||||
Putline("%d INTF %d\n", seq, protocol)
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " INTF "...)
|
||||
b = append(b, strconv.FormatInt(int64(protocol), 10)...)
|
||||
b = append(b, '\n')
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
func Message(filename string) (from string, rcpts []string, auth string, ip string, qid int, body []byte, err error) {
|
||||
|
||||
qid, err = strconv.Atoi((filename)[strings.LastIndexByte(filename, '/')+1 : strings.LastIndexByte(filename, '.')])
|
||||
func MainDomain() (string, error) {
|
||||
h, err := os.Open("Settings/Main.settings")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return
|
||||
return "", err
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
var line []byte
|
||||
var pos int64
|
||||
rd := bufio.NewReader(h)
|
||||
key := []byte("DomainName")
|
||||
|
||||
for m := bufio.NewReader(h); ; {
|
||||
|
||||
line, err = m.ReadSlice('\n')
|
||||
if err != nil {
|
||||
return
|
||||
for {
|
||||
line, err := rd.ReadSlice('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pos += int64(len(line))
|
||||
|
||||
if string(line) == "\n" {
|
||||
// Ищем вхождение DomainName
|
||||
idxKey := bytes.Index(line, key)
|
||||
if idxKey == -1 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
switch line[0] {
|
||||
case 'P':
|
||||
s := strings.IndexByte(string(line), '<')
|
||||
from = string(line[s : s+strings.IndexByte(string(line[s:]), '>')+1])
|
||||
|
||||
case 'R':
|
||||
s := strings.IndexByte(string(line), '<')
|
||||
rcpts = append(rcpts, string(line[s:s+strings.IndexByte(string(line[s:]), '>')+1]))
|
||||
|
||||
case 'S':
|
||||
if s := reSMTP.FindAllStringSubmatch(string(line), -1); s != nil {
|
||||
auth = s[0][1]
|
||||
ip = s[0][2]
|
||||
} else if s := reSELF.FindAllStringSubmatch(string(line), -1); s != nil {
|
||||
auth = s[0][1]
|
||||
ip = "127.2.4.7"
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
rcpts = uniqueNonEmptyElementsOf(rcpts)
|
||||
|
||||
fi, err := h.Stat()
|
||||
if err != nil {
|
||||
return
|
||||
// Проверяем, что перед ключом стоит разделитель (начало строки, пробел, { или ;)
|
||||
if idxKey > 0 {
|
||||
prev := line[idxKey-1]
|
||||
if prev != ' ' && prev != '\t' && prev != '{' && prev != ';' {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
_, err = h.Seek(pos, os.SEEK_SET)
|
||||
if err != nil {
|
||||
return
|
||||
// Ищем '=' после ключа
|
||||
lineAfterKey := line[idxKey+len(key):]
|
||||
idxEq := bytes.IndexByte(lineAfterKey, '=')
|
||||
if idxEq == -1 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
body = make([]byte, fi.Size()-pos)
|
||||
n, err := h.Read(body)
|
||||
if err != nil {
|
||||
return
|
||||
// Ищем ';' после '='
|
||||
idxSemi := bytes.IndexByte(lineAfterKey[idxEq:], ';')
|
||||
if idxSemi == -1 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
// Извлекаем и чистим значение
|
||||
rawVal := lineAfterKey[idxEq+1 : idxEq+idxSemi]
|
||||
cleanVal := bytes.Trim(rawVal, " \t\r\n\"")
|
||||
|
||||
if len(cleanVal) > 0 {
|
||||
return string(cleanVal), nil
|
||||
}
|
||||
|
||||
return
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("DomainName not found in settings")
|
||||
}
|
||||
|
||||
func MirrorTo(seq int, to []string, headers []string) {
|
||||
func MirrorTo(seq int, m *Message, to []string, headers []string, discard bool) {
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
defer putBuffer(buf)
|
||||
|
||||
hdrs := replaceSpecChars(strings.Join(headers, "\n"))
|
||||
// SEQ [ADDHEADER "h1\eh2"] [MIRRORTO "rcpt"] {DISCARD|OK}
|
||||
buf.WriteString(strconv.Itoa(seq))
|
||||
|
||||
if protocol >= 4 {
|
||||
|
||||
if len(to) > 0 {
|
||||
|
||||
mirrorTo := []string{}
|
||||
for _, m := range to {
|
||||
mirrorTo = append(mirrorTo, fmt.Sprintf("MIRRORTO \"%s\"", m))
|
||||
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("\"")
|
||||
}
|
||||
|
||||
Putline("%d %s ADDHEADER \"%s\" OK\n", seq, strings.Join(mirrorTo, " "), hdrs)
|
||||
for _, rcpt := range to {
|
||||
buf.WriteString(" MIRRORTO \"")
|
||||
replaceSpecCharsBuf(buf, rcpt)
|
||||
buf.WriteString("\"")
|
||||
}
|
||||
|
||||
if discard {
|
||||
buf.WriteString(" DISCARD")
|
||||
} else {
|
||||
Putline("%d ADDHEADER \"%s\" OK\n", seq, hdrs)
|
||||
buf.WriteString(" OK")
|
||||
}
|
||||
|
||||
} else {
|
||||
Putline("%d ADDHEADER \"%s\"\n", seq, hdrs)
|
||||
res := buf.Bytes()
|
||||
if len(res) > 4096 {
|
||||
Failure(seq, m.QID, fmt.Errorf("MirrorTo: result exceeds 4k limit (%d bytes)", len(res)))
|
||||
return
|
||||
}
|
||||
|
||||
if len(res) > 0 && res[len(res)-1] != '\n' {
|
||||
buf.WriteByte('\n')
|
||||
res = buf.Bytes()
|
||||
}
|
||||
|
||||
syscall.Write(stdoutFd, res)
|
||||
}
|
||||
|
||||
func Ok(seq int) {
|
||||
Putline("%d OK\n", seq)
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " OK\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
func Putline(format string, a ...interface{}) {
|
||||
s := fmt.Sprintf(format, a...)
|
||||
syscall.Write(int(os.Stdout.Fd()), []byte(s))
|
||||
func OkSeen(seq, qid int) {
|
||||
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(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 replaceSpecChars(msg string) string {
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(msg) + 128)
|
||||
|
||||
for _, symbol := range msg {
|
||||
|
||||
switch symbol {
|
||||
case rune('\\'):
|
||||
// replace \ -> \\ (CGP backslash)
|
||||
sb.WriteString("\\\\")
|
||||
|
||||
case 0x0000:
|
||||
fallthrough
|
||||
case rune('\r'):
|
||||
continue
|
||||
|
||||
case rune('\n'):
|
||||
// replace \n -> \\e (CGP End-of-Line)
|
||||
sb.WriteString("\\e")
|
||||
|
||||
case rune('\t'):
|
||||
// replace \t -> \\t (CGP Tab)
|
||||
sb.WriteString("\\t")
|
||||
|
||||
case rune('"'):
|
||||
// replace \" -> \\" (CGP quote)
|
||||
sb.WriteString("\\\"")
|
||||
|
||||
default:
|
||||
sb.WriteRune(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func setMainDomain() (err error) {
|
||||
|
||||
h, err := os.Open("Settings/Main.settings")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
var line []byte
|
||||
|
||||
for m := bufio.NewReader(h); ; {
|
||||
|
||||
line, err = m.ReadSlice('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s := reMD.FindAllStringSubmatch(string(line), -1); s != nil {
|
||||
MainDomain = s[0][1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func uniqueNonEmptyElementsOf(s []string) []string {
|
||||
|
||||
unique := make(map[string]bool, len(s))
|
||||
us := make([]string, len(unique))
|
||||
|
||||
for _, elem := range s {
|
||||
if len(elem) != 0 {
|
||||
if !unique[elem] {
|
||||
us = append(us, elem)
|
||||
unique[elem] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return us
|
||||
var buf [64]byte
|
||||
b := strconv.AppendInt(buf[:0], int64(seq), 10)
|
||||
b = append(b, " REJECT Try again later\n"...)
|
||||
syscall.Write(stdoutFd, b)
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainDomain(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
err := os.MkdirAll(filepath.Join(tmpDir, "Settings"), 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Тестируем разные варианты написания в Main.settings
|
||||
content := []byte(`
|
||||
OtherKey = 123;
|
||||
DomainName = "relay1.domain.name" ;
|
||||
UnquotedDomain = domain.name;
|
||||
# CommentedDomain = ignore.me;
|
||||
`)
|
||||
|
||||
settingsPath := filepath.Join(tmpDir, "Settings", "Main.settings")
|
||||
if err := os.WriteFile(settingsPath, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Подменяем рабочую директорию
|
||||
oldWd, _ := os.Getwd()
|
||||
os.Chdir(tmpDir)
|
||||
defer os.Chdir(oldWd)
|
||||
|
||||
t.Run("Extract Quoted Domain", func(t *testing.T) {
|
||||
got, err := MainDomain()
|
||||
if err != nil {
|
||||
t.Fatalf("MainDomain failed: %v", err)
|
||||
}
|
||||
if got != "relay1.domain.name" {
|
||||
t.Errorf("Got %q, want %q", got, "relay1.domain.name")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProtocolCommands(t *testing.T) {
|
||||
// Создаем pipe, чтобы перехватить то, что функции пишут в stdoutFd
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
|
||||
// Сохраняем старый дескриптор и подменяем на наш pipe
|
||||
oldFd := stdoutFd
|
||||
stdoutFd = int(w.Fd())
|
||||
defer func() { stdoutFd = oldFd }()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fn func()
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Ok command",
|
||||
fn: func() { Ok(123) },
|
||||
want: "123 OK\n",
|
||||
},
|
||||
{
|
||||
name: "Discard command",
|
||||
fn: func() { Discard(456) },
|
||||
want: "456 DISCARD\n",
|
||||
},
|
||||
{
|
||||
name: "Reject command",
|
||||
fn: func() { Reject(789) },
|
||||
want: "789 REJECT Try again later\n",
|
||||
},
|
||||
{
|
||||
name: "Failure command",
|
||||
fn: func() { Failure(10, 200, fmt.Errorf("test error")) },
|
||||
want: "* 10 [200]: test error\n10 FAILURE\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.fn()
|
||||
|
||||
buf := make([]byte, 256)
|
||||
n, _ := r.Read(buf)
|
||||
got := string(buf[:n])
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("Got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddHeader_ProtocolHandling(t *testing.T) {
|
||||
r, w, _ := os.Pipe()
|
||||
oldFd := stdoutFd
|
||||
stdoutFd = int(w.Fd())
|
||||
defer func() { stdoutFd = oldFd }()
|
||||
|
||||
t.Run("Protocol v4 with OK", func(t *testing.T) {
|
||||
protocol = 4
|
||||
AddHeader(1, []string{"X-Test: True"})
|
||||
|
||||
buf := make([]byte, 256)
|
||||
n, _ := r.Read(buf)
|
||||
got := string(buf[:n])
|
||||
|
||||
if !strings.Contains(got, "\" OK\n") {
|
||||
t.Errorf("Protocol v4 should append OK, got: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutline(t *testing.T) {
|
||||
r, w, _ := os.Pipe()
|
||||
oldFd := stdoutFd
|
||||
stdoutFd = int(w.Fd())
|
||||
defer func() { stdoutFd = oldFd }()
|
||||
|
||||
t.Run("Variadic mixed types", func(t *testing.T) {
|
||||
Putline("* ", 1, " error: ", fmt.Errorf("fail"))
|
||||
|
||||
buf := make([]byte, 256)
|
||||
n, _ := r.Read(buf)
|
||||
got := string(buf[:n])
|
||||
|
||||
want := "* 1 error: fail\n"
|
||||
if got != want {
|
||||
t.Errorf("Putline mismatch. Got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/maypok86/otter"
|
||||
)
|
||||
|
||||
func filterNames(names []string) []string {
|
||||
|
||||
fqdn := make([]string, 0, len(names))
|
||||
nonfqdn := make([]string, 0, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
if IsValidDomain(name) {
|
||||
fqdn = append(fqdn, name)
|
||||
} else {
|
||||
nonfqdn = append(nonfqdn, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(fqdn) > 0 {
|
||||
return fqdn
|
||||
} else {
|
||||
return nonfqdn
|
||||
}
|
||||
}
|
||||
|
||||
func findLongerItem(items []string) (res string) {
|
||||
if len(items) > 0 {
|
||||
var maxlen = 0
|
||||
var pos int
|
||||
for p, item := range items {
|
||||
if len(item) > maxlen {
|
||||
maxlen = len(item)
|
||||
pos = p
|
||||
}
|
||||
}
|
||||
res = items[pos]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func findShorterItem(items []string) (res string) {
|
||||
if len(items) > 0 {
|
||||
var maxlen = len(items[0])
|
||||
var pos int
|
||||
for p, item := range items {
|
||||
if len(item) < maxlen {
|
||||
maxlen = len(item)
|
||||
pos = p
|
||||
}
|
||||
}
|
||||
res = items[pos]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var getHostname = func() func(addr string) (hostname string) {
|
||||
|
||||
cache, _ := otter.MustBuilder[netip.Addr, string](1000).
|
||||
CollectStats().
|
||||
Cost(func(key netip.Addr, value string) uint32 {
|
||||
return 1
|
||||
}).
|
||||
WithVariableTTL().
|
||||
Build()
|
||||
|
||||
return func(addr string) (hostname string) {
|
||||
|
||||
ipAddr, err := netip.ParseAddr(addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hostname, ok := cache.Get(ipAddr)
|
||||
if !ok {
|
||||
hostname = lookupAddr(ipAddr)
|
||||
if len(hostname) > 0 {
|
||||
cache.Set(ipAddr, hostname, time.Hour)
|
||||
} else {
|
||||
cache.Set(ipAddr, hostname, 5*time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
func IsValidDomain(domain string) bool {
|
||||
if len(domain) == 0 || len(domain) > 253 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fast path: IP-адреса (v4 и v6) обычно начинаются с цифры или двоеточия.
|
||||
// Домены по RFC могут начинаться с цифры, но это редкость.
|
||||
// Если первый символ - цифра или ':', проверяем, не IP ли это.
|
||||
first := domain[0]
|
||||
if (first >= '0' && first <= '9') || first == ':' {
|
||||
if _, err := netip.ParseAddr(strings.TrimSuffix(domain, ".")); err == nil {
|
||||
return false // Это чистый IP
|
||||
}
|
||||
}
|
||||
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
lastDot := -1
|
||||
hasDot := false
|
||||
|
||||
for i := 0; i < len(domain); i++ {
|
||||
c := domain[i]
|
||||
if c == '.' {
|
||||
labelLen := i - lastDot - 1
|
||||
// Пустые метки (..) или дефис по краям метки недопустимы
|
||||
if labelLen == 0 || labelLen > 63 || domain[lastDot+1] == '-' || domain[i-1] == '-' {
|
||||
return false
|
||||
}
|
||||
lastDot = i
|
||||
hasDot = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Допустимые символы: [a-zA-Z0-9_-]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка последней метки (TLD)
|
||||
finalLabelLen := len(domain) - lastDot - 1
|
||||
if finalLabelLen == 0 || finalLabelLen > 63 || domain[lastDot+1] == '-' || domain[len(domain)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
return hasDot
|
||||
}
|
||||
|
||||
func lookupAddr(ipAddr netip.Addr) (hostname string) {
|
||||
|
||||
r := &net.Resolver{PreferGo: true}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
names, err := r.LookupAddr(ctx, ipAddr.String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(names) == 1 {
|
||||
hostname = strings.TrimSuffix(names[0], ".")
|
||||
return
|
||||
}
|
||||
|
||||
type result struct{ name string }
|
||||
ch := make(chan result, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
go func(name string) {
|
||||
ips, err := r.LookupHost(ctx, name)
|
||||
if err != nil {
|
||||
ch <- result{}
|
||||
return
|
||||
}
|
||||
for _, ip := range ips {
|
||||
ipTest, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ipTest == ipAddr {
|
||||
ch <- result{name}
|
||||
return
|
||||
}
|
||||
}
|
||||
ch <- result{}
|
||||
}(name)
|
||||
}
|
||||
|
||||
found := make([]string, 0, len(names))
|
||||
for range names {
|
||||
if r := <-ch; r.name != "" {
|
||||
found = append(found, r.name)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(found) {
|
||||
case 0:
|
||||
// если ни один name не имеет корректного прямого резольвинга
|
||||
// в исходный ip, выбираем самый длинный.
|
||||
hostname = findLongerItem(filterNames(names))
|
||||
|
||||
case 1:
|
||||
hostname = found[0]
|
||||
|
||||
default:
|
||||
// если несколько name имеют корректный прямой резольвинг
|
||||
// в исходный ip, выбираем самый короткий.
|
||||
hostname = findShorterItem(filterNames(found))
|
||||
}
|
||||
|
||||
hostname = strings.TrimSuffix(hostname, ".")
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostname_Logic(t *testing.T) {
|
||||
t.Run("FilterNames", func(t *testing.T) {
|
||||
input := []string{"host", "mail.domain.name", "localhost", "deep.sub.domain.com"}
|
||||
// Должны остаться только те, что проходят IsValidDomain (с точками)
|
||||
got := filterNames(input)
|
||||
if len(got) != 3 { // mail.domain.name, localhost (если rx пропустит), deep...
|
||||
// Зависит от вашего regex. IsValidDomain требует хотя бы одну точку.
|
||||
t.Logf("Filtered names: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FindShorterItem", func(t *testing.T) {
|
||||
input := []string{"very-long-hostname.example.com", "short.com", "medium.example.com"}
|
||||
want := "short.com"
|
||||
if got := findShorterItem(input); got != want {
|
||||
t.Errorf("findShorterItem() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FindLongerItem", func(t *testing.T) {
|
||||
input := []string{"a.ru", "abc.ru", "ab.ru"}
|
||||
want := "abc.ru"
|
||||
if got := findLongerItem(input); got != want {
|
||||
t.Errorf("findLongerItem() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IsValidDomain", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
dom string
|
||||
want bool
|
||||
}{
|
||||
{"domain.name", true},
|
||||
{"mail.domain.name.", true}, // с точкой в конце
|
||||
{"internal-host", false}, // нет точек
|
||||
{"1.2.3.4", false}, // IP не должен быть доменом в этой логике
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsValidDomain(tt.dom); got != tt.want {
|
||||
t.Errorf("IsValidDomain(%q) = %v, want %v", tt.dom, got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func extractAngle(line []byte) (string, bool) {
|
||||
s := strings.IndexByte(string(line), '<')
|
||||
if s < 0 {
|
||||
return "", false
|
||||
}
|
||||
e := strings.IndexByte(string(line[s:]), '>')
|
||||
if e < 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(line[s+1 : s+e]), true
|
||||
}
|
||||
|
||||
func getHeader(m *bufio.Reader, buf *bytes.Buffer) error {
|
||||
for {
|
||||
line, err := m.ReadSlice('\n')
|
||||
if err != nil && err != bufio.ErrBufferFull {
|
||||
if len(line) > 0 {
|
||||
buf.Write(line)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
buf.Write(line)
|
||||
|
||||
if err == bufio.ErrBufferFull {
|
||||
if buf.Len() > 64*1024 {
|
||||
return fmt.Errorf("header too long")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isHeaderEnd(line) {
|
||||
return nil
|
||||
}
|
||||
|
||||
next, err := m.Peek(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if next[0] != ' ' && next[0] != '\t' {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getHelo(hdr []byte) string {
|
||||
if !bytes.HasPrefix(hdr, []byte("Received: from ")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
data := hdr[15:]
|
||||
|
||||
// Received: from muus52.sndsy.ru ([185.235.30.52] verified)
|
||||
if idxOpen := bytes.Index(data, []byte(" ([")); idxOpen > 0 {
|
||||
if bytes.Contains(data[idxOpen:], []byte(" verified)")) {
|
||||
return string(bytes.TrimSpace(data[:idxOpen]))
|
||||
}
|
||||
}
|
||||
|
||||
// Received: from [77.83.39.182] (HELO poseidonms.com)
|
||||
// Received: from [10.19.5.40] (account test@domain.name HELO test.domain.name)
|
||||
if idxHelo := bytes.Index(data, []byte("HELO ")); idxHelo > 0 {
|
||||
prevChar := data[idxHelo-1]
|
||||
if prevChar == ' ' || prevChar == '(' {
|
||||
remaining := data[idxHelo+5:]
|
||||
if end := bytes.IndexByte(remaining, ')'); end != -1 {
|
||||
return string(bytes.TrimSpace(remaining[:end]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func putBuffer(buf *bytes.Buffer) {
|
||||
if buf.Cap() > 4096 {
|
||||
return
|
||||
}
|
||||
buf.Reset()
|
||||
bufferPool.Put(buf)
|
||||
}
|
||||
|
||||
func replaceSpecCharsBuf(buf *bytes.Buffer, s string) {
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
buf.WriteString("\\\\")
|
||||
case '\n':
|
||||
buf.WriteString("\\e")
|
||||
case '\t':
|
||||
buf.WriteString("\\t")
|
||||
case '"':
|
||||
buf.WriteString("\\\"")
|
||||
case '\r', 0x00:
|
||||
continue
|
||||
default:
|
||||
if r < utf8.RuneSelf {
|
||||
buf.WriteByte(byte(r))
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetHeader_Folding(t *testing.T) {
|
||||
// Проверяем корректность сборки многострочных заголовков (RFC folding)
|
||||
raw := "Subject: This is a very long\n\t subject line\nNext-Header: value\n"
|
||||
rd := bufio.NewReader(strings.NewReader(raw))
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
err := getHeader(rd, buf)
|
||||
if err != nil {
|
||||
t.Fatalf("getHeader failed: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
// Должен захватить обе строки, так как вторая начинается с табуляции
|
||||
if !strings.Contains(got, "subject line") {
|
||||
t.Errorf("Header folding failed. Got: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "Next-Header") {
|
||||
t.Error("getHeader read too far into the next header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHelo_Variants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hdr string
|
||||
want string
|
||||
}{
|
||||
{"Standard verified", "Received: from mail.domain.name ([1.2.3.4] verified)\n", "mail.domain.name"},
|
||||
{"HELO in brackets", "Received: from [1.2.3.4] (HELO poseidonms.com)\n", "poseidonms.com"},
|
||||
{"Account and HELO", "Received: from [10.19.5.40] (account test@domain.name HELO test.domain.name)\n", "test.domain.name"},
|
||||
{"Malformed", "Received: from broken (", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := getHelo([]byte(tt.hdr)); got != tt.want {
|
||||
t.Errorf("getHelo() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceSpecCharsBuf(t *testing.T) {
|
||||
buf := new(bytes.Buffer)
|
||||
input := "Path\\To\nFile\t\"Name\"\r"
|
||||
// \ -> \\, \n -> \e, \t -> \t, " -> \", \r -> skip
|
||||
expected := "Path\\\\To\\eFile\\t\\\"Name\\\""
|
||||
|
||||
replaceSpecCharsBuf(buf, input)
|
||||
if buf.String() != expected {
|
||||
t.Errorf("replaceSpecCharsBuf failed.\nGot: %q\nWant: %q", buf.String(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHeaderEnd(t *testing.T) {
|
||||
if !isHeaderEnd([]byte("\n")) {
|
||||
t.Error("Failed to detect LF as header end")
|
||||
}
|
||||
if !isHeaderEnd([]byte("\r\n")) {
|
||||
t.Error("Failed to detect CRLF as header end")
|
||||
}
|
||||
if isHeaderEnd([]byte("Subject: \n")) {
|
||||
t.Error("False positive for header end")
|
||||
}
|
||||
}
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/utils"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
File *os.File
|
||||
QID int
|
||||
From string
|
||||
Rcpts []string
|
||||
Auth string
|
||||
IP string
|
||||
Helo string
|
||||
Hostname string
|
||||
|
||||
HdrPos int64 // Смещение до RFC заголовков (после конверта)
|
||||
BodyPos int64 // Смещение до RFC тела (после заголовков)
|
||||
Size int64 // Общий размер файла
|
||||
|
||||
seen bool
|
||||
extReceived []byte
|
||||
}
|
||||
|
||||
var (
|
||||
readerPool = sync.Pool{
|
||||
New: func() any { return bufio.NewReaderSize(nil, 4096) },
|
||||
}
|
||||
writerPool = sync.Pool{
|
||||
New: func() any { return bufio.NewWriterSize(nil, 4096) },
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
bRecvHdr = []byte("Received: ")
|
||||
bRecvFrom = []byte("Received: from ")
|
||||
bSeenHdr = []byte("X-Rspamd-Seen: ")
|
||||
bSubjHdr = []byte("Subject: ")
|
||||
)
|
||||
|
||||
var (
|
||||
// Тип источника: 1 - Внешний (SMTP/HTTP...), 2 - Внутренний (RULE/LIST...)
|
||||
sourceModules = map[string]int{
|
||||
"SMTP": 1, "HTTP": 1, "HTTPU": 1, "AIRSYNC": 1, "IMAP": 1, "RPOP": 1, "XIMSS": 1,
|
||||
"ALARM": 2, "DSN": 2, "GROUP": 2, "ICAL": 2, "LIST": 2, "LSTM": 2, "LSTO": 2,
|
||||
"MDN": 2, "PBX": 2, "PIPE": 2, "RULE": 2, "WEBUSER": 2,
|
||||
}
|
||||
)
|
||||
|
||||
func (m *Message) Close() error {
|
||||
return m.File.Close()
|
||||
}
|
||||
|
||||
func (m *Message) IsSeen() bool {
|
||||
return m.seen
|
||||
}
|
||||
|
||||
func (m *Message) MakeSeen() string {
|
||||
// Если внешних Received не найдено (внутреннее письмо),
|
||||
// возвращаем пустую строку, чтобы заголовок не добавлялся.
|
||||
if len(m.extReceived) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// bSeenHdr = []byte("X-Rspamd-Seen: ")
|
||||
// Собираем строку: имя заголовка + хеш от сохраненного оригинала
|
||||
return string(bSeenHdr) + hashReceived(m.extReceived)
|
||||
}
|
||||
|
||||
func NewMessage(seq int, filename string) (*Message, error) {
|
||||
// 1. Извлечение QID из имени файла (например, 12345.msg -> 12345)
|
||||
start := strings.LastIndexByte(filename, '/') + 1
|
||||
end := strings.LastIndexByte(filename, '.')
|
||||
if end <= start {
|
||||
return nil, fmt.Errorf("invalid filename: %s", filename)
|
||||
}
|
||||
|
||||
qid, err := strconv.Atoi(filename[start:end])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse QID failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. Открытие файла оригинала
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := &Message{
|
||||
File: f,
|
||||
QID: qid,
|
||||
Size: fi.Size(),
|
||||
}
|
||||
|
||||
// 3. Подготовка ридера из пула
|
||||
rd := getReader(f)
|
||||
defer putReader(rd)
|
||||
|
||||
var pos int64
|
||||
|
||||
// 4. Читаем Конверт (Envelope)
|
||||
for {
|
||||
line, err := rd.ReadSlice('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
err = fmt.Errorf("unexpected end of envelope")
|
||||
}
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
pos += int64(len(line))
|
||||
|
||||
if isHeaderEnd(line) {
|
||||
break
|
||||
}
|
||||
|
||||
// Парсинг параметров конверта
|
||||
switch line[0] {
|
||||
case 'P': // Return-Path
|
||||
if v, ok := extractAngle(line); ok {
|
||||
msg.From = v
|
||||
}
|
||||
case 'R': // Envelope-To
|
||||
if v, ok := extractAngle(line); ok {
|
||||
msg.Rcpts = append(msg.Rcpts, v)
|
||||
}
|
||||
case 'S': // Source/Interface info
|
||||
parseSource(seq, line, msg)
|
||||
}
|
||||
}
|
||||
msg.HdrPos = pos
|
||||
|
||||
// 5. Разбор Заголовков (Headers)
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
defer putBuffer(buf)
|
||||
|
||||
var (
|
||||
seenSum string
|
||||
seenFound bool
|
||||
seenProcessed bool
|
||||
extReceivedSaved bool
|
||||
)
|
||||
|
||||
for {
|
||||
buf.Reset()
|
||||
err := getHeader(rd, buf)
|
||||
hdr := buf.Bytes()
|
||||
|
||||
if len(hdr) > 0 {
|
||||
pos += int64(len(hdr))
|
||||
|
||||
if isHeaderEnd(hdr) {
|
||||
break
|
||||
}
|
||||
|
||||
// А. Проверка существующей метки Seen (Прошлое)
|
||||
// Доверяем порядку: Метка -> (опционально не-Received) -> Первый Received
|
||||
if !seenProcessed {
|
||||
if !seenFound {
|
||||
if bytes.HasPrefix(hdr, bSeenHdr) {
|
||||
seenSum = string(bytes.TrimSpace(hdr[len(bSeenHdr):]))
|
||||
seenFound = true
|
||||
}
|
||||
} else if bytes.HasPrefix(hdr, bRecvHdr) {
|
||||
// Валидируем по первому же встречному Received
|
||||
if hashReceived(hdr) == seenSum {
|
||||
msg.seen = true
|
||||
}
|
||||
seenProcessed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Б. Идентификация точки входа (Будущее)
|
||||
// Нас интересует только первый внешний Received.
|
||||
if !extReceivedSaved && isExternal(hdr) {
|
||||
msg.extReceived = bytes.Clone(hdr)
|
||||
extReceivedSaved = true
|
||||
msg.Helo = getHelo(hdr)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
msg.BodyPos = pos
|
||||
|
||||
// Очистка списка получателей от теоретических дублей
|
||||
msg.Rcpts = utils.UniqueSliceElementsNonEmpty(msg.Rcpts)
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (m *Message) PrintMsgInfo() {
|
||||
fmt.Fprintln(os.Stderr, "from: ", m.From)
|
||||
fmt.Fprintln(os.Stderr, "rcpts: ", m.Rcpts)
|
||||
fmt.Fprintln(os.Stderr, "ip: ", m.IP)
|
||||
fmt.Fprintln(os.Stderr, "helo: ", m.Helo)
|
||||
fmt.Fprintln(os.Stderr, "hostname:", m.Hostname)
|
||||
fmt.Fprintln(os.Stderr, "qid: ", m.QID)
|
||||
if len(m.Auth) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "auth: ", m.Auth)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "auth: not authenticated")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "seen: ", m.seen)
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
|
||||
func (m *Message) RewriteSubject(headers []string, subject string) (err error) {
|
||||
sQid := strconv.Itoa(m.QID)
|
||||
filetemp := submitDir + "/" + sQid + "rs.tmp"
|
||||
filename := submitDir + "/" + sQid + "rs.sub"
|
||||
|
||||
fh, err := os.Create(filetemp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RewriteSubject: create tmp failed: %w", err)
|
||||
}
|
||||
|
||||
w := getWriter(fh)
|
||||
success := false
|
||||
|
||||
defer func() {
|
||||
if !success {
|
||||
putWriter(w)
|
||||
fh.Close()
|
||||
os.Remove(filetemp)
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. Формируем конверт (Submitted требует Return-Path и Envelope-To)
|
||||
w.WriteString("Return-Path: ")
|
||||
w.WriteString(m.From)
|
||||
w.WriteByte('\n')
|
||||
for _, r := range m.Rcpts {
|
||||
w.WriteString("Envelope-To: ")
|
||||
w.WriteString(r)
|
||||
w.WriteByte('\n')
|
||||
}
|
||||
|
||||
// 2. Метаданные (Seen, Junk-Score, DKIM).
|
||||
for _, h := range headers {
|
||||
w.WriteString(h)
|
||||
w.WriteByte('\n')
|
||||
}
|
||||
|
||||
// 3. Подготовка к чтению оригинальных заголовков
|
||||
if _, err = m.File.Seek(m.HdrPos, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rd := getReader(m.File)
|
||||
defer putReader(rd)
|
||||
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
defer putBuffer(buf)
|
||||
|
||||
// 4. Цикл обработки заголовков: копируем всё, заменяя Subject
|
||||
for {
|
||||
buf.Reset()
|
||||
if err = getHeader(rd, buf); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
hdr := buf.Bytes()
|
||||
|
||||
if isHeaderEnd(hdr) {
|
||||
w.Write(hdr)
|
||||
break
|
||||
}
|
||||
|
||||
// Заменяем оригинальный Subject на новый
|
||||
if bytes.HasPrefix(hdr, bSubjHdr) {
|
||||
w.Write(bSubjHdr)
|
||||
w.WriteString(subject)
|
||||
w.WriteByte('\n')
|
||||
continue
|
||||
}
|
||||
|
||||
w.Write(hdr)
|
||||
}
|
||||
|
||||
// 5. Перенос тела письма напрямую через io.Copy
|
||||
if _, err = m.File.Seek(m.BodyPos, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = io.Copy(w, m.File); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = fh.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Rename(filetemp, filename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
success = true
|
||||
putWriter(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getReader(r io.Reader) *bufio.Reader {
|
||||
rd := readerPool.Get().(*bufio.Reader)
|
||||
rd.Reset(r)
|
||||
return rd
|
||||
}
|
||||
|
||||
func getWriter(w io.Writer) *bufio.Writer {
|
||||
bw := writerPool.Get().(*bufio.Writer)
|
||||
bw.Reset(w)
|
||||
return bw
|
||||
}
|
||||
|
||||
func hashReceived(b []byte) string {
|
||||
h := xxhash.New()
|
||||
|
||||
start := 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
// Нормализация: игнорируем пробелы, табы и переносы строк (folding)
|
||||
if b[i] <= 32 {
|
||||
if i > start {
|
||||
h.Write(b[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Дописываем остаток, если он есть
|
||||
if start < len(b) {
|
||||
h.Write(b[start:])
|
||||
}
|
||||
|
||||
// Результат — 64-битное число, приводим к hex (16 символов)
|
||||
return strconv.FormatUint(h.Sum64(), 16)
|
||||
}
|
||||
|
||||
func isExternal(hdr []byte) bool {
|
||||
// 1. Проверяем полный префикс.
|
||||
if !bytes.HasPrefix(hdr, bRecvFrom) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Смещаемся за "Received: from "
|
||||
fromVal := hdr[len(bRecvFrom):]
|
||||
if len(fromVal) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Быстрый чек на IP в скобках [IP] (SMTP/Web)
|
||||
if fromVal[0] == '[' {
|
||||
return true
|
||||
}
|
||||
|
||||
// 4. Ищем границу идентификатора (до первого пробела)
|
||||
firstSpace := bytes.IndexByte(fromVal, ' ')
|
||||
if firstSpace == -1 {
|
||||
firstSpace = len(fromVal)
|
||||
}
|
||||
firstWord := fromVal[:firstSpace]
|
||||
|
||||
// 5. Проверка на Email (GROUP/RULE).
|
||||
// Если во входном идентификаторе нет '@', это сетевой хост.
|
||||
return len(firstWord) > 0 && !bytes.Contains(firstWord, []byte("@"))
|
||||
}
|
||||
|
||||
func isHeaderEnd(hdr []byte) bool {
|
||||
return len(hdr) == 1 && hdr[0] == '\n' ||
|
||||
(len(hdr) == 2 && hdr[0] == '\r' && hdr[1] == '\n')
|
||||
}
|
||||
|
||||
/*
|
||||
внешние источники: SMTP|HTTPU?|AIRSYNC|IMAP|RPOP|XIMSS
|
||||
S <test@domain.name> AIRSYNC [83.139.170.75]
|
||||
S <test@domain.name> HTTP [46.72.226.199]
|
||||
S SMTP [130.193.65.125]
|
||||
S <test@domain.name> SMTP [2001:67c:418:2020::21]
|
||||
S RPOP [81.19.77.161]
|
||||
|
||||
внутренние источники: ALARM|DSN|GROUP|ICAL|LIST|LSTM|LSTO|MDN|PBX|PIPE|RULE|WEBUSER
|
||||
S DSN [0.0.0.0]
|
||||
S GROUP [0.0.0.0]
|
||||
S LIST [0.0.0.0]
|
||||
S LSTM [0.0.0.0]
|
||||
S <test@domain.name> MDN [0.0.0.0]
|
||||
S PIPE [0.0.0.0]
|
||||
S <postmaster@domain.name> RULE [0.0.0.0]
|
||||
*/
|
||||
func parseSource(seq int, line []byte, msg *Message) {
|
||||
// Строка вида: S [<auth> ]MODULE [IP]
|
||||
data := bytes.TrimSpace(line[1:])
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var auth string
|
||||
// 1. Извлекаем <auth>, если он присутствует
|
||||
if data[0] == '<' {
|
||||
endAuth := bytes.IndexByte(data, '>')
|
||||
if endAuth != -1 {
|
||||
auth = string(data[1:endAuth])
|
||||
data = bytes.TrimSpace(data[endAuth+1:])
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ищем IP в квадратных скобках в конце строки
|
||||
openBracket := bytes.LastIndexByte(data, '[')
|
||||
closeBracket := bytes.LastIndexByte(data, ']')
|
||||
|
||||
if openBracket == -1 || closeBracket == -1 || closeBracket <= openBracket {
|
||||
Putline("* ", seq, " [", msg.QID, "]: unknown S-line format: ", utils.Bytes2string(line))
|
||||
return
|
||||
}
|
||||
|
||||
moduleBytes := bytes.TrimSpace(data[:openBracket])
|
||||
ipBytes := data[openBracket+1 : closeBracket]
|
||||
|
||||
mType, known := sourceModules[utils.Bytes2string(moduleBytes)]
|
||||
|
||||
// 3. Заполнение структуры
|
||||
if known && mType == 2 && bytes.Equal(ipBytes, []byte("0.0.0.0")) {
|
||||
// Внутренний источник (trusted)
|
||||
if auth != "" {
|
||||
msg.Auth = auth
|
||||
} else {
|
||||
msg.Auth = string(moduleBytes) + "@trusted"
|
||||
}
|
||||
msg.IP = "127.2.4.7"
|
||||
} else if known && mType == 1 {
|
||||
// Внешний источник (SMTP, и т.д.)
|
||||
msg.Auth = auth
|
||||
msg.IP = string(ipBytes)
|
||||
msg.Hostname = getHostname(msg.IP)
|
||||
} else {
|
||||
Putline("* ", seq, " [", msg.QID, "]: unknown or mismatched module in S-line: ", string(line))
|
||||
}
|
||||
}
|
||||
|
||||
func putReader(rd *bufio.Reader) {
|
||||
rd.Reset(nil)
|
||||
readerPool.Put(rd)
|
||||
}
|
||||
|
||||
func putWriter(wr *bufio.Writer) {
|
||||
wr.Reset(nil)
|
||||
writerPool.Put(wr)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// MessageMock помогает собирать тестовые файлы сообщений CGP в байтовый массив
|
||||
type MessageMock struct {
|
||||
Envelope []string
|
||||
Headers []string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (m *MessageMock) Render() []byte {
|
||||
var sb strings.Builder
|
||||
// 1. Конверт
|
||||
for _, line := range m.Envelope {
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteByte('\n') // Пустая строка - признак конца конверта
|
||||
|
||||
// 2. RFC Заголовки
|
||||
for _, line := range m.Headers {
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteByte('\n') // Пустая строка - признак конца заголовков
|
||||
|
||||
// 3. Тело
|
||||
sb.WriteString(m.Body)
|
||||
|
||||
return []byte(sb.String())
|
||||
}
|
||||
|
||||
// createTestFile создает структуру папок Submitted и пишет туда файл сообщения
|
||||
func createTestFile(t *testing.T, qid int, content []byte) string {
|
||||
tmpDir := t.TempDir()
|
||||
subDir := filepath.Join(tmpDir, submitDir)
|
||||
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
filename := filepath.Join(subDir, strconv.Itoa(qid)+".msg")
|
||||
if err := os.WriteFile(filename, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func TestNewMessage_Parsing(t *testing.T) {
|
||||
rawRecv := "Received: from mail.domain.name ([1.2.3.4] verified)"
|
||||
mock := &MessageMock{
|
||||
Envelope: []string{
|
||||
"P <sender@domain.name>",
|
||||
"R <rcpt1@domain.name>",
|
||||
"R <rcpt2@domain.name>",
|
||||
"S SMTP [1.2.3.4]",
|
||||
},
|
||||
Headers: []string{
|
||||
rawRecv,
|
||||
"Subject: Test",
|
||||
"From: sender@domain.name",
|
||||
},
|
||||
Body: "Hello world!",
|
||||
}
|
||||
|
||||
content := mock.Render()
|
||||
qid := 10001
|
||||
fname := createTestFile(t, qid, content)
|
||||
|
||||
msg, err := NewMessage(1, fname)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMessage failed: %v", err)
|
||||
}
|
||||
defer msg.Close()
|
||||
|
||||
// Проверка извлечения данных
|
||||
if msg.QID != qid {
|
||||
t.Errorf("QID mismatch: got %d, want %d", msg.QID, qid)
|
||||
}
|
||||
if msg.From != "sender@domain.name" {
|
||||
t.Errorf("From mismatch: got %s", msg.From)
|
||||
}
|
||||
if len(msg.Rcpts) != 2 {
|
||||
t.Errorf("Rcpts count mismatch: got %d", len(msg.Rcpts))
|
||||
}
|
||||
if msg.IP != "1.2.3.4" {
|
||||
t.Errorf("IP mismatch: got %s", msg.IP)
|
||||
}
|
||||
if msg.Helo != "mail.domain.name" {
|
||||
t.Errorf("Helo mismatch: got %s", msg.Helo)
|
||||
}
|
||||
|
||||
// Проверка смещений (HdrPos должен указывать на 'R' в 'Received')
|
||||
expectedHdrPos := int64(strings.Index(string(content), "Received:"))
|
||||
if msg.HdrPos != expectedHdrPos {
|
||||
t.Errorf("HdrPos: got %d, want %d", msg.HdrPos, expectedHdrPos)
|
||||
}
|
||||
|
||||
// Проверка смещения тела (BodyPos после пустой строки после заголовков)
|
||||
expectedBodyPos := int64(strings.Index(string(content), "Hello world!"))
|
||||
if msg.BodyPos != expectedBodyPos {
|
||||
t.Errorf("BodyPos: got %d, want %d", msg.BodyPos, expectedBodyPos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessage_RewriteSubject(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
subDir := filepath.Join(tmpDir, submitDir)
|
||||
os.MkdirAll(subDir, 0755)
|
||||
|
||||
oldWd, _ := os.Getwd()
|
||||
os.Chdir(tmpDir)
|
||||
defer os.Chdir(oldWd)
|
||||
|
||||
// Подготовка мока сообщения
|
||||
rawRecv := "Received: from mail.domain.name ([1.2.3.4] verified)"
|
||||
mock := &MessageMock{
|
||||
Envelope: []string{"P <s@domain.name>", "R <r@domain.name>", "S SMTP [1.2.3.4]"},
|
||||
Headers: []string{
|
||||
"From: s@domain.name",
|
||||
rawRecv, // Наш целевой Received для HELO и Seen
|
||||
"Subject: Old",
|
||||
},
|
||||
Body: "Body Content",
|
||||
}
|
||||
|
||||
qid := 20002
|
||||
// Создаем тестовый файл .msg
|
||||
fname := createTestFile(t, qid, mock.Render())
|
||||
|
||||
// 1. Инициализируем сообщение (должно найти внешний Received и сохранить его)
|
||||
msg, err := NewMessage(1, fname)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMessage failed: %v", err)
|
||||
}
|
||||
defer msg.Close()
|
||||
|
||||
// 2. Генерируем Seen-заголовок через метод структуры (логика rspamc)
|
||||
seenHeader := msg.MakeSeen()
|
||||
if len(seenHeader) == 0 {
|
||||
t.Fatalf("MakeSeen returned empty string, check if NewMessage saved rawReceived")
|
||||
}
|
||||
|
||||
newSubj := "SPAM: Original"
|
||||
// Собираем слайс дополнительных заголовков
|
||||
rspamdHdrs := []string{
|
||||
"X-Spam-Score: 10.0",
|
||||
seenHeader, // Добавляем сгенерированный Seen
|
||||
}
|
||||
|
||||
// 3. Выполняем рерайт в директорию Submitted
|
||||
err = msg.RewriteSubject(rspamdHdrs, newSubj)
|
||||
if err != nil {
|
||||
t.Fatalf("RewriteSubject failed: %v", err)
|
||||
}
|
||||
|
||||
// 4. Проверяем результат в .sub файле
|
||||
resPath := filepath.Join(subDir, "20002rs.sub")
|
||||
res, err := os.ReadFile(resPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Result file %s not found", resPath)
|
||||
}
|
||||
sRes := string(res)
|
||||
|
||||
// А. Проверка хеша
|
||||
// Важно: hashReceived должен работать идентично внутри MakeSeen и здесь в тесте
|
||||
expectedHash := hashReceived([]byte(rawRecv + "\n"))
|
||||
if !strings.Contains(sRes, "X-Rspamd-Seen: "+expectedHash) {
|
||||
t.Errorf("Seen header missing or hash mismatch.\nExpected hash: %s\nFull content:\n%s", expectedHash, sRes)
|
||||
}
|
||||
|
||||
// Б. Проверка замены темы
|
||||
expectedSubjLine := "Subject: " + newSubj
|
||||
if !strings.Contains(sRes, expectedSubjLine) {
|
||||
t.Errorf("New subject %q not found in file", expectedSubjLine)
|
||||
}
|
||||
|
||||
// В. Проверка удаления старой темы
|
||||
if strings.Contains(sRes, "Subject: Old") {
|
||||
t.Error("Old subject 'Subject: Old' still present in the file!")
|
||||
}
|
||||
|
||||
// Г. Проверка наличия заголовков из слайса
|
||||
if !strings.Contains(sRes, "X-Spam-Score: 10.0") {
|
||||
t.Error("Rspamd headers (X-Spam-Score) missing in rewritten file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMessage_Malformed(t *testing.T) {
|
||||
t.Run("Unexpected EOF", func(t *testing.T) {
|
||||
// Обрываем файл прямо в середине конверта
|
||||
content := []byte("P <sender@domain.name>\nS SMTP [1.2.3.4]")
|
||||
fname := createTestFile(t, 30003, content)
|
||||
|
||||
_, err := NewMessage(1, fname)
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected end of envelope") {
|
||||
t.Errorf("Expected EOF error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package cgp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NotifyTo(seq int, m *Message, to []string, header string, notifyfrom string, desc string) {
|
||||
mailid := strconv.Itoa(m.QID)
|
||||
sSeq := strconv.Itoa(seq)
|
||||
filename := submitDir + "/" + mailid + "no.sub"
|
||||
filetemp := submitDir + "/" + mailid + "no.tmp"
|
||||
|
||||
// Локальная функция-обертка для изоляции ресурсов (File, Writer)
|
||||
err := func() error {
|
||||
fh, err := os.Create(filetemp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
w := bufio.NewWriterSize(fh, 8192)
|
||||
defer w.Flush()
|
||||
|
||||
date := time.Now().Format(time.RFC1123Z)
|
||||
boundary := "nextPart" + mailid + "." + sSeq
|
||||
|
||||
// 1. Конверт и заголовки MIME
|
||||
w.WriteString("Return-Path: <>\n")
|
||||
for _, rcpt := range to {
|
||||
w.WriteString("Envelope-To: <")
|
||||
w.WriteString(rcpt)
|
||||
w.WriteString(">\n")
|
||||
}
|
||||
w.WriteString("From: ")
|
||||
w.WriteString(notifyfrom)
|
||||
w.WriteString("\nSubject: Notify Case ")
|
||||
w.WriteString(mailid)
|
||||
w.WriteString("\nDate: ")
|
||||
w.WriteString(date)
|
||||
w.WriteByte('\n')
|
||||
w.WriteString(header) // X-Rspamd-Case
|
||||
w.WriteString("\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"")
|
||||
w.WriteString(boundary)
|
||||
w.WriteString("\"\n\nThis is a multi-part message in MIME format.\n\n--")
|
||||
w.WriteString(boundary)
|
||||
|
||||
// 2. Текстовое описание кейса
|
||||
w.WriteString("\nContent-Type: text/plain; charset=UTF-8; format=flowed\nContent-Transfer-Encoding: 8bit\n\nmail id: ")
|
||||
w.WriteString(mailid)
|
||||
w.WriteString("\nmail from: ")
|
||||
if len(m.From) > 0 {
|
||||
w.WriteString(m.From)
|
||||
} else {
|
||||
w.WriteString("<>")
|
||||
}
|
||||
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 {
|
||||
os.Remove(filetemp)
|
||||
Putline("* ", sSeq, " [", mailid, "]: notify: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.Rename(filetemp, filename); err != nil {
|
||||
os.Remove(filetemp)
|
||||
Putline("* ", sSeq, " [", mailid, "]: notify rename error: ", err)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+135
-30
@@ -2,42 +2,147 @@ package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"math"
|
||||
"strings"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/configor"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
)
|
||||
|
||||
// Эти значения будут перезаписаны при сборке через -ldflags
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
Description string
|
||||
Direction Direction
|
||||
Discard bool
|
||||
MirrorTo []string
|
||||
NotifyRcpts bool
|
||||
NotifyTo []string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AuthservId string
|
||||
Debug bool
|
||||
Host string
|
||||
MirrorTo []string
|
||||
RejectScore float64
|
||||
Timeout time.Duration
|
||||
Host string `default:"localhost:11333"`
|
||||
Timeout time.Duration `default:"15s"`
|
||||
NotifyFrom string `required:"true"`
|
||||
Actions map[string]*Operation
|
||||
Symbols map[string]*Operation
|
||||
debug bool
|
||||
outbound bool
|
||||
showVersion bool
|
||||
}
|
||||
|
||||
func New() *Config {
|
||||
var configInstance *Config
|
||||
|
||||
config := new(Config)
|
||||
|
||||
var mirrorTo string
|
||||
|
||||
flag.StringVar(&config.AuthservId, "authserv-id", "", "Authentication Identifier (default CommuniGate Pro Main Domain)")
|
||||
flag.StringVar(&config.Host, "host", "localhost:11333", "Rspamd host to connect")
|
||||
flag.StringVar(&mirrorTo, "mirror-to", "", "Mirror selected messages to email")
|
||||
flag.Float64Var(&config.RejectScore, "reject-score", math.Inf(1), "Reject score to discard")
|
||||
flag.DurationVar(&config.Timeout, "timeout", 15*time.Second, "Rspamd request timeout")
|
||||
flag.BoolVar(&config.Debug, "debug", false, "Export debug information (for developers)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if len(mirrorTo) > 0 {
|
||||
config.MirrorTo = strings.Split(strings.ReplaceAll(mirrorTo, " ", ""), ",")
|
||||
}
|
||||
|
||||
if config.Timeout < time.Second {
|
||||
config.Timeout *= time.Second
|
||||
}
|
||||
|
||||
return config
|
||||
func Action(a string) (op *Operation, ok bool) {
|
||||
op, ok = configInstance.Actions[a]
|
||||
return
|
||||
}
|
||||
|
||||
func AuthservId() string {
|
||||
return configInstance.AuthservId
|
||||
}
|
||||
|
||||
func Debug() bool {
|
||||
return configInstance.debug
|
||||
}
|
||||
|
||||
func GetVersion() string {
|
||||
return fmt.Sprintf("rspamd-cgp\n version: %s\n commit: %s\n built: %s\n",
|
||||
Version, Commit, BuildTime)
|
||||
}
|
||||
|
||||
func Host() string {
|
||||
return configInstance.Host
|
||||
}
|
||||
|
||||
func New(args []string) (*Config, error) {
|
||||
|
||||
c := &Config{}
|
||||
|
||||
fs := flag.NewFlagSet("rspamd-cgp", flag.ContinueOnError)
|
||||
|
||||
dir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||
|
||||
var configfile string
|
||||
var configdump bool
|
||||
var configtest bool
|
||||
var err error
|
||||
|
||||
fs.StringVar(&configfile, "config", dir+"/rspamd-cgp.yml", "Set configuration file")
|
||||
fs.BoolVar(&configdump, "configdump", false, "Perform configuration file dump")
|
||||
fs.BoolVar(&configtest, "configtest", false, "Perform configuration file test")
|
||||
fs.BoolVar(&c.debug, "debug", false, "Run in debug mode")
|
||||
fs.BoolVar(&c.outbound, "outbound", false, "Outbound message flow processing")
|
||||
fs.BoolVar(&c.showVersion, "version", false, "print version and exit")
|
||||
|
||||
if err = fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = configor.Load(c, configfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if configdump {
|
||||
dumpConfig(c)
|
||||
if err = validateConfig(c); err != nil {
|
||||
fmt.Println("config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if configtest {
|
||||
if err = validateConfig(c); err != nil {
|
||||
fmt.Println("config:", err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
fmt.Println("config: Syntax OK")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
if c.AuthservId == "" {
|
||||
if c.AuthservId, err = cgp.MainDomain(); err != nil {
|
||||
return nil, fmt.Errorf("can not detect Main Domain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.Host = "http://" + c.Host + "/checkv2"
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func NotifyFrom() string {
|
||||
return configInstance.NotifyFrom
|
||||
}
|
||||
|
||||
func Outbound() bool {
|
||||
return configInstance.outbound
|
||||
}
|
||||
|
||||
func SetGlobal(c *Config) {
|
||||
configInstance = c
|
||||
}
|
||||
|
||||
func ShowVersion() bool {
|
||||
return configInstance.showVersion
|
||||
}
|
||||
|
||||
func Symbols() map[string]*Operation {
|
||||
return configInstance.Symbols
|
||||
}
|
||||
|
||||
func Timeout() time.Duration {
|
||||
return configInstance.Timeout
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfig_New(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
// Готовим тестовый конфиг.
|
||||
// YAML теперь парсится через UnmarshalYAML в наш тип Direction.
|
||||
yamlContent := `
|
||||
authservid: "test.domain.name"
|
||||
notifyfrom: "postmaster@test.domain.name"
|
||||
host: "127.0.0.1:11333"
|
||||
actions:
|
||||
reject:
|
||||
description: "Spam rejected"
|
||||
direction: "in"
|
||||
notifyto: ["admin@domain.name"]
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(yamlContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("Full initialization", func(t *testing.T) {
|
||||
args := []string{"-config", configFile, "-debug"}
|
||||
cfg, err := New(args)
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Timeout != 15*time.Second {
|
||||
t.Errorf("Expected default timeout 15s, got %v", cfg.Timeout)
|
||||
}
|
||||
if cfg.AuthservId != "test.domain.name" {
|
||||
t.Errorf("AuthservId mismatch: %s", cfg.AuthservId)
|
||||
}
|
||||
if !cfg.debug {
|
||||
t.Error("Debug flag was not set")
|
||||
}
|
||||
|
||||
expectedHost := "http://127.0.0.1:11333/checkv2"
|
||||
if cfg.Host != expectedHost {
|
||||
t.Errorf("Host transformation failed. Got %s, want %s", cfg.Host, expectedHost)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Direction enum parsing", func(t *testing.T) {
|
||||
args := []string{"-config", configFile}
|
||||
cfg, _ := New(args)
|
||||
|
||||
op, ok := cfg.Actions["reject"]
|
||||
// Проверяем соответствие константе DirIn (1)
|
||||
if !ok || op.Direction != DirIn {
|
||||
t.Errorf("Action 'reject' should have direction DirIn (1), got %v", op.Direction)
|
||||
}
|
||||
// Проверка строкового представления
|
||||
if op.Direction.String() != "in" {
|
||||
t.Errorf("String representation failed. Got %s, want 'in'", op.Direction.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid direction in YAML", func(t *testing.T) {
|
||||
badHdr := "notifyfrom: \"a@b.c\"\nactions:\n bad:\n direction: \"upwards\""
|
||||
badFile := filepath.Join(tmpDir, "bad_config.yml")
|
||||
os.WriteFile(badFile, []byte(badHdr), 0644)
|
||||
|
||||
_, err := New([]string{"-config", badFile})
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid direction 'upwards', but got nil")
|
||||
} else if !strings.Contains(err.Error(), "invalid direction") {
|
||||
t.Errorf("Unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid config",
|
||||
cfg: &Config{
|
||||
NotifyFrom: "scanner@domain.name",
|
||||
Actions: map[string]*Operation{
|
||||
"quarantine": {Direction: DirBoth},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid email",
|
||||
cfg: &Config{
|
||||
NotifyFrom: "not-an-email",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid direction enum",
|
||||
cfg: &Config{
|
||||
NotifyFrom: "ok@domain.name",
|
||||
Actions: map[string]*Operation{
|
||||
"test": {Direction: Direction(99)}, // Несуществующее значение
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateConfig(tt.cfg)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionOutput(t *testing.T) {
|
||||
Version = "1.0.0"
|
||||
Commit = "abcde"
|
||||
|
||||
out := GetVersion()
|
||||
if !contains(out, "1.0.0") || !contains(out, "abcde") {
|
||||
t.Errorf("Version output mismatch: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_AuthservIdFallback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsDir := filepath.Join(tmpDir, "Settings")
|
||||
os.MkdirAll(settingsDir, 0755)
|
||||
|
||||
mainSettingsContent := []byte(`DomainName = "domain.name";`)
|
||||
os.WriteFile(filepath.Join(settingsDir, "Main.settings"), mainSettingsContent, 0644)
|
||||
|
||||
configFile := filepath.Join(tmpDir, "config.yml")
|
||||
yamlContent := []byte("notifyfrom: \"postmaster@domain.name\"\nhost: \"localhost:11333\"")
|
||||
os.WriteFile(configFile, yamlContent, 0644)
|
||||
|
||||
oldWd, _ := os.Getwd()
|
||||
os.Chdir(tmpDir)
|
||||
defer os.Chdir(oldWd)
|
||||
|
||||
t.Run("Detect AuthservId from Main.settings", func(t *testing.T) {
|
||||
args := []string{"-config", configFile}
|
||||
cfg, err := New(args)
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed to detect domain: %v", err)
|
||||
}
|
||||
|
||||
if cfg.AuthservId != "domain.name" {
|
||||
t.Errorf("Expected AuthservId 'domain.name', got %q", cfg.AuthservId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
DirBoth Direction = iota
|
||||
DirIn
|
||||
DirOut
|
||||
)
|
||||
|
||||
// MarshalYAML позволяет Marshal печатать строку вместо числа
|
||||
func (d Direction) MarshalYAML() (any, error) {
|
||||
return d.String(), nil
|
||||
}
|
||||
|
||||
// String реализует интерфейс fmt.Stringer
|
||||
func (d Direction) String() string {
|
||||
switch d {
|
||||
case DirIn:
|
||||
return "in"
|
||||
case DirOut:
|
||||
return "out"
|
||||
case DirBoth:
|
||||
return "both"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", d)
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalYAML позволяет прозрачно читать "in", "out", "both" из конфига
|
||||
func (d *Direction) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
var s string
|
||||
if err := unmarshal(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
switch s {
|
||||
case "in":
|
||||
*d = DirIn
|
||||
case "out":
|
||||
*d = DirOut
|
||||
case "both", "": // Пустое значение трактуем как both
|
||||
*d = DirBoth
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s (want: in, out, both)", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var reMail *regexp.Regexp
|
||||
|
||||
func dumpConfig(c *Config) {
|
||||
yml, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
fmt.Println("config:", err)
|
||||
} else {
|
||||
fmt.Print(string(yml))
|
||||
fmt.Println("debug:", c.debug)
|
||||
fmt.Println("outbound:", c.outbound)
|
||||
fmt.Println("showVersion:", c.showVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func validateConfig(c *Config) (err error) {
|
||||
|
||||
reMail = regexp.MustCompile(`^\S+?@\S+$`)
|
||||
|
||||
if err = validateConfigOp(c.NotifyFrom); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = validateConfigEntry(c.Actions); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = validateConfigEntry(c.Symbols); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func validateConfigEntry(entry map[string]*Operation) (err error) {
|
||||
|
||||
for e, op := range entry {
|
||||
|
||||
// Проверка диапазона Direction
|
||||
if op.Direction < DirBoth || op.Direction > DirOut {
|
||||
return fmt.Errorf("%s: invalid direction index: %d", e, op.Direction)
|
||||
}
|
||||
|
||||
if err = validateConfigOps(op.NotifyTo); err != nil {
|
||||
err = fmt.Errorf("%s: NotifyTo: %v", e, err)
|
||||
break
|
||||
}
|
||||
|
||||
if err = validateConfigOps(op.MirrorTo); err != nil {
|
||||
err = fmt.Errorf("%s: MirrorTo: %v", e, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func validateConfigOp(m string) (err error) {
|
||||
|
||||
if !reMail.MatchString(m) {
|
||||
err = fmt.Errorf("invalid mail: %s", m)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func validateConfigOps(mail []string) (err error) {
|
||||
|
||||
for _, m := range mail {
|
||||
if err = validateConfigOp(m); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
domain.ru
|
||||
@@ -0,0 +1 @@
|
||||
/domain\.ru/i
|
||||
@@ -0,0 +1,41 @@
|
||||
################################################################################
|
||||
# Пример multimap.conf для Rspamd
|
||||
################################################################################
|
||||
|
||||
FAKE_LOCAL_FROM {
|
||||
require_symbols = "!AUTHENTICATED_USER & !TRUSTED_HOST & !CGP_RPOLL";
|
||||
type = "header";
|
||||
header = "from";
|
||||
filter = "email:domain";
|
||||
map = "$LOCAL_CONFDIR/local.d/maps.d/local_domains.map";
|
||||
description = "Fake local mail in From: header";
|
||||
}
|
||||
|
||||
FAKE_LOCAL_FROM_NAME {
|
||||
require_symbols = "!AUTHENTICATED_USER & !TRUSTED_HOST & !CGP_RPOLL";
|
||||
type = "header";
|
||||
header = "from";
|
||||
filter = "email:name";
|
||||
map = "$LOCAL_CONFDIR/local.d/maps.d/local_domains_re.map";
|
||||
regexp = true;
|
||||
description = "Fake local mail in Realname of From: header";
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Используется rspamd-cgp для исключения внешних адресов из рассылки оповещений.
|
||||
# Применяется, если в конфигурационном файле rspamd-cgp.yml указан
|
||||
# notifyrcpts: true
|
||||
#
|
||||
RCPTS_DOMAINS_LOCAL {
|
||||
type = "rcpt";
|
||||
extract_from = "smtp";
|
||||
filter = "email:domain";
|
||||
map = "$LOCAL_CONFDIR/local.d/maps.d/local_domains.map";
|
||||
description = "Recipients domains are local";
|
||||
}
|
||||
|
||||
TRUSTED_HOST {
|
||||
type = "ip";
|
||||
map = "$LOCAL_CONFDIR/local.d/maps.d/trusted_hosts.map";
|
||||
description = "Trusted hosts";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
local reconf = config['regexp']
|
||||
|
||||
reconf['CGP_RPOLL'] = {
|
||||
description = 'CommuniGate Pro RPOLL Received header',
|
||||
re = 'Received=/with RPOLL /{raw_header}'
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
################################################################################
|
||||
# Пример settings.conf для Rspamd.
|
||||
################################################################################
|
||||
|
||||
################################################################################
|
||||
# Устанавливает символ AUTHENTICATED_USER для аутентифицированных сообщений.
|
||||
#
|
||||
authenticated {
|
||||
priority = high;
|
||||
authenticated = yes;
|
||||
apply {
|
||||
AUTHENTICATED_USER = 0.0;
|
||||
}
|
||||
symbols [
|
||||
"AUTHENTICATED_USER"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1.2.3.4
|
||||
@@ -1,10 +1,16 @@
|
||||
module git.vsu.ru/ai/rspamd-cgp
|
||||
|
||||
go 1.18
|
||||
|
||||
require github.com/json-iterator/go v1.1.12
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/jinzhu/configor v1.2.2
|
||||
github.com/maypok86/otter v1.2.4
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/dolthub/maphash v0.1.0 // indirect
|
||||
github.com/gammazero/deque v1.2.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
|
||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||
github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
|
||||
github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
|
||||
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
|
||||
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -2,52 +2,74 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
"git.vsu.ru/ai/rspamd-cgp/config"
|
||||
"git.vsu.ru/ai/rspamd-cgp/rspamc"
|
||||
"git.vsu.ru/ai/rspamd-cgp/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
var arg, cmd string
|
||||
var seq int
|
||||
var line []byte
|
||||
var err error
|
||||
var n int
|
||||
if err := cgp.InitStdoutFd(); err != nil {
|
||||
os.Stderr.WriteString("failed to init CGP stdout\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
conf, err := config.New(os.Args[1:])
|
||||
if err != nil {
|
||||
os.Stderr.WriteString("config: " + err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
config.SetGlobal(conf)
|
||||
|
||||
if config.ShowVersion() {
|
||||
os.Stderr.WriteString(config.GetVersion())
|
||||
return
|
||||
}
|
||||
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
|
||||
finalize:
|
||||
loop:
|
||||
for {
|
||||
line, err = in.ReadSlice('\n')
|
||||
line, err := in.ReadSlice('\n')
|
||||
if err != nil {
|
||||
cgp.Putline("* error: %s\n", err)
|
||||
break finalize
|
||||
if err != io.EOF {
|
||||
cgp.Putline("* stdin error: ", err)
|
||||
}
|
||||
break loop
|
||||
}
|
||||
|
||||
n, err = fmt.Sscan(string(line), &seq, &cmd, &arg)
|
||||
if err != nil && err != io.EOF {
|
||||
cgp.Putline("* error: %s\n", err)
|
||||
parts := bytes.Fields(line)
|
||||
n := len(parts)
|
||||
if n < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
seq, err := strconv.Atoi(utils.Bytes2string(parts[0]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := utils.Bytes2string(parts[1])
|
||||
|
||||
switch {
|
||||
case cmd == "FILE" && n == 3:
|
||||
go rspamc.Scan(seq, arg)
|
||||
go rspamc.Scan(seq, string(parts[2]))
|
||||
|
||||
case cmd == "INTF" && n == 3:
|
||||
cgp.Intf(seq, arg)
|
||||
cgp.Intf(seq, utils.Bytes2string(parts[2]))
|
||||
|
||||
case cmd == "QUIT" && n == 2:
|
||||
cgp.Ok(seq)
|
||||
break finalize
|
||||
break loop
|
||||
|
||||
default:
|
||||
cgp.Putline("* bad command: %s\n", line)
|
||||
cgp.Putline("* bad command: ", line)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+221
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package rspamc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
"git.vsu.ru/ai/rspamd-cgp/config"
|
||||
"git.vsu.ru/ai/rspamd-cgp/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
headerCase string = "X-Rspamd-Case: "
|
||||
headerJunkG string = "X-Junk-Score: [XX]"
|
||||
headerJunkA string = "X-Junk-Score: [XXXX]"
|
||||
headerJunkR string = "X-Junk-Score: [XXXXXXXXXX]"
|
||||
)
|
||||
|
||||
func filterLocalRcpts(rcpts []string, res *RspamdResponse) []string {
|
||||
filtered := make([]string, 0, len(rcpts))
|
||||
|
||||
if res.Symbols == nil {
|
||||
return filtered
|
||||
}
|
||||
|
||||
v, ok := res.Symbols["RCPTS_DOMAINS_LOCAL"]
|
||||
if !ok || v == nil || len(v.Options) == 0 {
|
||||
return filtered
|
||||
}
|
||||
|
||||
for _, rcpt := range rcpts {
|
||||
at := strings.IndexByte(rcpt, '@')
|
||||
if at < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rcptDomain := rcpt[at+1 : len(rcpt)-1]
|
||||
for _, domain := range v.Options {
|
||||
if rcptDomain == domain {
|
||||
filtered = append(filtered, rcpt)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isOpAccept(dir config.Direction, outbound bool) bool {
|
||||
switch dir {
|
||||
case config.DirBoth:
|
||||
return true
|
||||
case config.DirOut:
|
||||
return outbound
|
||||
case config.DirIn:
|
||||
return !outbound
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func makeHeaders(res *RspamdResponse) (headers []string) {
|
||||
if res.DKIMSignature != "" {
|
||||
headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
|
||||
}
|
||||
|
||||
if res.Milter != nil && res.Milter.AddHeaders != nil {
|
||||
for h, vh := range res.Milter.AddHeaders {
|
||||
if vh.Value != "" {
|
||||
headers = append(headers, h+": "+vh.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func makeHeadersOutbound(res *RspamdResponse) (headers []string) {
|
||||
if res.DKIMSignature != "" {
|
||||
headers = append(headers, "DKIM-Signature: "+res.DKIMSignature)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func makeOpSum(res *RspamdResponse, action string) (*config.Operation, string, string, string) {
|
||||
var casea []string
|
||||
var desca []string
|
||||
opsum := new(config.Operation)
|
||||
|
||||
// Обработка Action
|
||||
if op, ok := config.Action(action); ok {
|
||||
if isOpAccept(op.Direction, config.Outbound()) {
|
||||
opsum.Discard = op.Discard
|
||||
opsum.MirrorTo = op.MirrorTo
|
||||
opsum.NotifyRcpts = op.NotifyRcpts
|
||||
opsum.NotifyTo = op.NotifyTo
|
||||
casea = append(casea, action)
|
||||
|
||||
if len(op.Description) > 0 {
|
||||
desca = append(desca, action+": "+op.Description)
|
||||
} else {
|
||||
desca = append(desca, action)
|
||||
}
|
||||
|
||||
if config.Debug() {
|
||||
printSelectedOp("Action", action, op.Direction, config.Outbound())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка Symbols
|
||||
if res.Symbols != nil {
|
||||
for symbol, op := range config.Symbols() {
|
||||
if isOpAccept(op.Direction, config.Outbound()) {
|
||||
if v, ok := res.Symbols[symbol]; ok && v != nil {
|
||||
opsum.Discard = opsum.Discard || op.Discard
|
||||
opsum.MirrorTo = append(opsum.MirrorTo, op.MirrorTo...)
|
||||
opsum.NotifyRcpts = opsum.NotifyRcpts || op.NotifyRcpts
|
||||
opsum.NotifyTo = append(opsum.NotifyTo, op.NotifyTo...)
|
||||
casea = append(casea, symbol)
|
||||
|
||||
if len(op.Description) > 0 {
|
||||
desca = append(desca, symbol+": "+op.Description)
|
||||
} else if v.Description != "" {
|
||||
desca = append(desca, symbol+": "+v.Description)
|
||||
} else {
|
||||
desca = append(desca, symbol)
|
||||
}
|
||||
|
||||
if config.Debug() {
|
||||
printSelectedOp("Symbol", symbol, op.Direction, config.Outbound())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(casea) > 0 {
|
||||
var sb strings.Builder
|
||||
sb.Grow(256)
|
||||
|
||||
// Формирования casework
|
||||
sb.WriteString("discard ")
|
||||
sb.WriteString(strconv.FormatBool(opsum.Discard))
|
||||
sb.WriteString("; notifyrcpts ")
|
||||
sb.WriteString(strconv.FormatBool(opsum.NotifyRcpts))
|
||||
|
||||
if len(opsum.MirrorTo) > 0 {
|
||||
opsum.MirrorTo = utils.UniqueSliceElementsNonEmpty(opsum.MirrorTo)
|
||||
sb.WriteString("; mirrorto ")
|
||||
sb.WriteString(strings.Join(opsum.MirrorTo, ","))
|
||||
}
|
||||
|
||||
if len(opsum.NotifyTo) > 0 {
|
||||
opsum.NotifyTo = utils.UniqueSliceElementsNonEmpty(opsum.NotifyTo)
|
||||
sb.WriteString("; notifyto ")
|
||||
sb.WriteString(strings.Join(opsum.NotifyTo, ","))
|
||||
}
|
||||
|
||||
return opsum, strings.Join(casea, ","), sb.String(), strings.Join(desca, "\n")
|
||||
}
|
||||
|
||||
return nil, "", "", ""
|
||||
}
|
||||
|
||||
func printSelectedOp(optype, opname string, dir config.Direction, outbound bool) {
|
||||
|
||||
if outbound {
|
||||
fmt.Fprintf(os.Stderr, "%s '%s' selected for outbound flow: direction %s\n", optype, opname, dir.String())
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "%s '%s' selected for inbound flow: direction %s\n", optype, opname, dir.String())
|
||||
}
|
||||
}
|
||||
|
||||
func printResponse(debugBuf *bytes.Buffer) {
|
||||
var pretty bytes.Buffer
|
||||
if err := json.Indent(&pretty, debugBuf.Bytes(), "", " "); err == nil {
|
||||
os.Stderr.Write(pretty.Bytes())
|
||||
os.Stderr.Write([]byte("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// procAction обрабатывает вердикт Rspamd согласно BNF:
|
||||
// SEQ [ADDHEADER "h"] [MIRRORTO "a"] {OK|DISCARD}
|
||||
func procAction(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse,
|
||||
headers []string, hci, notifyfrom, desc string, t int) {
|
||||
|
||||
var actualMirror []string
|
||||
var discard bool
|
||||
|
||||
// Уведомления
|
||||
procNotifications(seq, msg, opsum, res, hci, notifyfrom, desc)
|
||||
|
||||
// Определение намерения
|
||||
if opsum != nil {
|
||||
discard = opsum.Discard
|
||||
actualMirror = opsum.MirrorTo
|
||||
} else {
|
||||
discard = (t == 2)
|
||||
}
|
||||
|
||||
// Валидация MirrorTo (Seen tag)
|
||||
if len(actualMirror) > 0 {
|
||||
if seenHdr := msg.MakeSeen(); seenHdr != "" {
|
||||
headers = append(headers, seenHdr)
|
||||
} else {
|
||||
cgp.Putline("* ", seq, " [", msg.QID, "]: warning: MirrorTo skipped (no Seen tag)")
|
||||
actualMirror = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Оптимизация ADDHEADER
|
||||
if discard && len(actualMirror) == 0 {
|
||||
headers = nil
|
||||
}
|
||||
|
||||
// Финальный ответ
|
||||
cgp.MirrorTo(seq, msg, actualMirror, headers, discard)
|
||||
}
|
||||
|
||||
// procActionRS обрабатывает Rewrite Subject через создание .sub файла в Submitted/
|
||||
func procActionRS(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse,
|
||||
headers []string, hci, notifyfrom, desc string) {
|
||||
|
||||
// Уведомления
|
||||
procNotifications(seq, msg, opsum, res, hci, notifyfrom, desc)
|
||||
|
||||
seenHdr := msg.MakeSeen()
|
||||
|
||||
// FALLBACK: Метки нет — НИКАКИХ MIRRORTO.
|
||||
// Мы только модифицируем текущее письмо (ADDHEADER) и отдаем OK.
|
||||
if seenHdr == "" {
|
||||
cgp.Putline("* ", seq, " [", msg.QID, "]: warning: RewriteSubject fallback (no Seen tag). MirrorTo suppressed.")
|
||||
|
||||
// Добавляем заголовок с предложенной темой к оригиналу
|
||||
headers = append(headers, "X-Spam-Subject: "+res.Subject)
|
||||
|
||||
// Явный вызов метода, не имеющего отношения к дублированию писем
|
||||
cgp.AddHeader(seq, headers)
|
||||
return
|
||||
}
|
||||
|
||||
// ШТАТНЫЙ РЕЖИМ: Метка есть, можем безопасно делать MIRRORTO
|
||||
headers = append(headers, seenHdr)
|
||||
|
||||
// Пишем новый файл в Submitted/
|
||||
if err := msg.RewriteSubject(headers, res.Subject); err != nil {
|
||||
cgp.Failure(seq, msg.QID, fmt.Errorf("RewriteSubject failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Удаляем оригинал.
|
||||
// Если в opsum был MirrorTo, он уйдет в этой же строке (SEQ [MIRRORTO] DISCARD).
|
||||
var actualMirror []string
|
||||
if opsum != nil {
|
||||
actualMirror = opsum.MirrorTo
|
||||
}
|
||||
|
||||
// Финальный ответ
|
||||
cgp.MirrorTo(seq, msg, actualMirror, headers, true)
|
||||
}
|
||||
|
||||
func procActionSwitch(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, headers []string, hci, action, desc string) {
|
||||
outbound := config.Outbound()
|
||||
notifyFrom := config.NotifyFrom()
|
||||
|
||||
switch action {
|
||||
case "no action":
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 0)
|
||||
|
||||
case "discard":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkR)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 2)
|
||||
|
||||
case "quarantine", "reject":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkR)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
|
||||
case "rewrite subject":
|
||||
if res.Subject != "" {
|
||||
procActionRS(seq, msg, opsum, res, headers, hci, notifyFrom, desc)
|
||||
} else {
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkA)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
}
|
||||
|
||||
case "add header":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkA)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
|
||||
case "greylist", "soft reject":
|
||||
if !outbound {
|
||||
headers = append(headers, headerJunkG)
|
||||
}
|
||||
procAction(seq, msg, opsum, res, headers, hci, notifyFrom, desc, 1)
|
||||
|
||||
default:
|
||||
cgp.Failure(seq, msg.QID, fmt.Errorf("Unknown action: %v", action))
|
||||
}
|
||||
}
|
||||
|
||||
func procNotifications(seq int, msg *cgp.Message, opsum *config.Operation, res *RspamdResponse, hci, notifyfrom, desc string) {
|
||||
if opsum == nil || (!opsum.NotifyRcpts && len(opsum.NotifyTo) == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Собираем всех кандидатов на получение уведомления
|
||||
to := make([]string, 0, len(opsum.NotifyTo)+len(msg.Rcpts))
|
||||
to = append(to, opsum.NotifyTo...)
|
||||
|
||||
if opsum.NotifyRcpts {
|
||||
// Фильтруем только локальных или специфичных получателей на основе вердикта
|
||||
rcptsFiltered := filterLocalRcpts(msg.Rcpts, res)
|
||||
to = append(to, rcptsFiltered...)
|
||||
}
|
||||
|
||||
// Убираем дубликаты и пустые строки перед отправкой
|
||||
to = utils.UniqueSliceElementsNonEmpty(to)
|
||||
|
||||
if len(to) > 0 && notifyfrom != "" {
|
||||
cgp.NotifyTo(seq, msg, to, hci, notifyfrom, desc)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+105
-123
@@ -2,174 +2,156 @@ package rspamc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
json "github.com/json-iterator/go"
|
||||
"sync"
|
||||
|
||||
"git.vsu.ru/ai/rspamd-cgp/cgp"
|
||||
"git.vsu.ru/ai/rspamd-cgp/config"
|
||||
)
|
||||
|
||||
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 (
|
||||
headerJunkG string = "X-Junk-Score: [XX]"
|
||||
headerJunkA string = "X-Junk-Score: [XXXX]"
|
||||
headerJunkR string = "X-Junk-Score: [XXXXXXXXXX]"
|
||||
client *http.Client
|
||||
clientOne sync.Once
|
||||
)
|
||||
|
||||
var authservId string
|
||||
var client *http.Client
|
||||
var mirrorTo []string
|
||||
var rejectScore float64
|
||||
var host string
|
||||
var debug bool
|
||||
|
||||
func init() {
|
||||
|
||||
config := config.New()
|
||||
|
||||
if config.AuthservId != "" {
|
||||
authservId = config.AuthservId
|
||||
} else {
|
||||
authservId = cgp.MainDomain
|
||||
}
|
||||
|
||||
mirrorTo = config.MirrorTo
|
||||
rejectScore = config.RejectScore
|
||||
host = "http://" + config.Host + "/checkv2"
|
||||
debug = config.Debug
|
||||
|
||||
tr := &http.Transport{
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
client = &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
Transport: tr,
|
||||
}
|
||||
}
|
||||
|
||||
func printResponse(v any) {
|
||||
printed, _ := json.MarshalIndent(v, "", " ")
|
||||
fmt.Fprintln(os.Stderr, string(printed))
|
||||
}
|
||||
|
||||
func Scan(seq int, filename string) {
|
||||
|
||||
from, rcpts, auth, ip, qid, body, err := cgp.Message(filename)
|
||||
clientOne.Do(func() {
|
||||
client = &http.Client{
|
||||
Timeout: config.Timeout(),
|
||||
Transport: &http.Transport{
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
msg, err := cgp.NewMessage(seq, filename)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
cgp.Failure(seq, 0, err)
|
||||
return
|
||||
}
|
||||
defer msg.Close()
|
||||
|
||||
if msg.IsSeen() {
|
||||
cgp.OkSeen(seq, msg.QID)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", host, bytes.NewReader(body))
|
||||
content := io.NewSectionReader(msg.File, msg.HdrPos, msg.Size-msg.HdrPos)
|
||||
req, err := http.NewRequest("POST", config.Host(), content)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
cgp.Failure(seq, msg.QID, err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Add("MTA-Tag", authservId)
|
||||
req.Header.Add("MTA-Tag", config.AuthservId())
|
||||
req.Header.Add("User-Agent", "rspamd-cgp")
|
||||
req.Header.Add("From", from)
|
||||
req.Header.Add("Queue-ID", strconv.Itoa(qid))
|
||||
if len(auth) > 0 {
|
||||
req.Header.Add("User", auth)
|
||||
req.Header.Add("From", msg.From)
|
||||
req.Header.Add("Queue-ID", strconv.Itoa(msg.QID))
|
||||
if len(msg.Auth) > 0 {
|
||||
req.Header.Add("User", msg.Auth)
|
||||
}
|
||||
if len(ip) > 0 {
|
||||
req.Header.Add("IP", ip)
|
||||
if len(msg.IP) > 0 {
|
||||
req.Header.Add("IP", msg.IP)
|
||||
}
|
||||
for _, rcpt := range rcpts {
|
||||
if len(msg.Helo) > 0 {
|
||||
req.Header.Add("Helo", msg.Helo)
|
||||
}
|
||||
if len(msg.Hostname) > 0 {
|
||||
req.Header.Add("Hostname", msg.Hostname)
|
||||
}
|
||||
for _, rcpt := range msg.Rcpts {
|
||||
req.Header.Add("Rcpt", rcpt)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
cgp.Failure(seq, msg.QID, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
rbody, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
var res RspamdResponse
|
||||
var reader io.Reader = resp.Body
|
||||
var debugBuf *bytes.Buffer
|
||||
|
||||
// Если Debug включен, копируем поток в буфер
|
||||
if config.Debug() {
|
||||
debugBuf = new(bytes.Buffer)
|
||||
reader = io.TeeReader(resp.Body, debugBuf)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(reader).Decode(&res); err != nil {
|
||||
cgp.Failure(seq, msg.QID, err)
|
||||
return
|
||||
}
|
||||
|
||||
var js map[string]interface{}
|
||||
if err := json.Unmarshal(rbody, &js); err != nil {
|
||||
cgp.Failure(seq, qid, err)
|
||||
if config.Debug() {
|
||||
msg.PrintMsgInfo()
|
||||
printResponse(debugBuf)
|
||||
}
|
||||
|
||||
action := res.Action
|
||||
if action == "" {
|
||||
cgp.Failure(seq, msg.QID, fmt.Errorf("missing or invalid 'action' field"))
|
||||
return
|
||||
}
|
||||
|
||||
if debug {
|
||||
printResponse(js)
|
||||
}
|
||||
opsum, caseinfo, casework, desc := makeOpSum(&res, action)
|
||||
|
||||
var headers []string
|
||||
|
||||
if _, ok := js["dkim-signature"]; ok {
|
||||
headers = append(headers, strings.Join([]string{"DKIM-Signature: ", js["dkim-signature"].(string)}, ""))
|
||||
}
|
||||
|
||||
if milter, ok := js["milter"]; ok {
|
||||
if hdrs, ok := milter.(map[string]interface{})["add_headers"]; ok {
|
||||
if reflect.TypeOf(hdrs).String() == "map[string]interface {}" {
|
||||
for h, vh := range hdrs.(map[string]interface{}) {
|
||||
if reflect.TypeOf(vh).String() == "map[string]interface {}" {
|
||||
if v, ok := vh.(map[string]interface{})["value"].(string); ok && v != "" {
|
||||
headers = append(headers, strings.Join([]string{h, v}, ": "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
action := js["action"]
|
||||
score := (js["score"]).(float64)
|
||||
|
||||
cgp.Putline("* %d [%d]: Action: %s; Score: %.2f/%.2f; Time elapsed: %.3fs\n",
|
||||
seq, qid, action, score, js["required_score"], js["time_real"])
|
||||
|
||||
switch action {
|
||||
case "no action":
|
||||
if len(headers) > 0 {
|
||||
cgp.AddHeader(seq, headers)
|
||||
if config.Outbound() {
|
||||
headers = makeHeadersOutbound(&res)
|
||||
} else {
|
||||
cgp.Ok(seq)
|
||||
headers = makeHeaders(&res)
|
||||
}
|
||||
|
||||
case "discard":
|
||||
cgp.Discard(seq)
|
||||
cgp.Putline("* ", seq, " [", msg.QID, "]: Action: ", action,
|
||||
"; Score: ", res.Score, "/", res.RequiredScore, "; Time elapsed: ", res.TimeReal)
|
||||
|
||||
case "quarantine":
|
||||
cgp.MirrorTo(seq, mirrorTo, append(headers, headerJunkR))
|
||||
|
||||
case "reject":
|
||||
if score >= rejectScore {
|
||||
cgp.Putline("* %d [%d]: Action set to discard due to Score(%.2f) >= rejectScore(%d)\n",
|
||||
seq, qid, score, rejectScore)
|
||||
cgp.Discard(seq)
|
||||
} else {
|
||||
cgp.AddHeader(seq, append(headers, headerJunkR))
|
||||
var hci string
|
||||
if opsum != nil {
|
||||
cgp.Putline("* ", seq, " [", msg.QID, "]: Case: ", caseinfo, "; ", casework)
|
||||
hci = headerCase + caseinfo
|
||||
if !config.Outbound() {
|
||||
headers = append(headers, hci)
|
||||
}
|
||||
}
|
||||
|
||||
case "rewrite subject":
|
||||
fallthrough
|
||||
case "add header":
|
||||
cgp.AddHeader(seq, append(headers, headerJunkA))
|
||||
|
||||
case "greylist":
|
||||
fallthrough
|
||||
case "soft reject":
|
||||
cgp.AddHeader(seq, append(headers, headerJunkG))
|
||||
|
||||
default:
|
||||
cgp.Failure(seq, qid, fmt.Errorf("Unknown action: %v", action))
|
||||
}
|
||||
procActionSwitch(seq, msg, opsum, &res, headers, hci, action, desc)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
################################################################################
|
||||
# rspamd-cgp config file
|
||||
################################################################################
|
||||
|
||||
################################################################################
|
||||
# Устанавливает значение authserv-id в заголовке Authentication-Results, RFC7001
|
||||
# Если не задан, ему присваивается имя главного домена CommuniGate Pro.
|
||||
#
|
||||
authservid: mx.domain.name
|
||||
|
||||
################################################################################
|
||||
# Адрес хоста и порт Rspamd, тайм-аут.
|
||||
#
|
||||
host: 127.0.0.1:11333
|
||||
timeout: 15s
|
||||
|
||||
################################################################################
|
||||
# Устанавливает значение заголовка From: в оповещениях.
|
||||
#
|
||||
notifyfrom: rspamd-cgp-notify@domain.name
|
||||
|
||||
################################################################################
|
||||
# Секция описывает действия (actions) и дополнительную обработку для каждого
|
||||
# действия. Здесь указываются действия, которым нужна дополнительная обработка.
|
||||
#
|
||||
# discard: # название действия
|
||||
# description: "discard action" # короткое описание действия
|
||||
# direction: in|out|both # применить действие для направления
|
||||
# discard: true # доставить сообщение или выбросить
|
||||
# notifyrcpts: false # оповещать ли получателей сообщения
|
||||
# notifyto: [] # список получателей оповещения
|
||||
# mirrorto: [aa@ba.ru, cc@dd.ru] # список получателей копии сообщения
|
||||
#
|
||||
actions:
|
||||
discard:
|
||||
description: "discard action"
|
||||
discard: true
|
||||
notifyrcpts: false
|
||||
notifyto: []
|
||||
mirrorto: [a@b.r, c@d.r]
|
||||
|
||||
################################################################################
|
||||
# Секция описывает символы (SYMBOLs) и дополнительную обработку для каждого
|
||||
# символа. Здесь указываются символы, которым нужна дополнительная обработка.
|
||||
#
|
||||
# FAKE_LOCAL_FROM: # название символа
|
||||
# description: "" # по умолчанию берётся из ответа Rspamd
|
||||
# direction: in|out|both # применить символ для направления
|
||||
# discard: true # доставить сообщение или выбросить
|
||||
# notifyrcpts: false # оповещать ли получателей сообщения
|
||||
# notifyto: [] # список получателей оповещения
|
||||
# mirrorto: [aa@ba.ru, cc@dd.ru] # список получателей копии сообщения
|
||||
#
|
||||
symbols:
|
||||
FAKE_LOCAL_FROM:
|
||||
discard: true
|
||||
notifyto: [a@b.ru]
|
||||
mirrorto: [c@d.ru]
|
||||
|
||||
FAKE_LOCAL_FROM_NAME:
|
||||
discard: true
|
||||
notifyto: [a@b.ru]
|
||||
mirrorto: [c@d.ru]
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,29 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func Bytes2string(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
return unsafe.String(unsafe.SliceData(b), len(b))
|
||||
}
|
||||
|
||||
func UniqueSliceElementsNonEmpty[T ~string](s []T) []T {
|
||||
|
||||
unique := make([]T, 0, len(s))
|
||||
seen := make(map[T]bool, len(s))
|
||||
|
||||
for _, e := range s {
|
||||
if len(e) > 0 {
|
||||
if !seen[e] {
|
||||
unique = append(unique, e)
|
||||
seen[e] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBytes2string(t *testing.T) {
|
||||
t.Run("Valid conversion", func(t *testing.T) {
|
||||
input := []byte("hello world")
|
||||
got := Bytes2string(input)
|
||||
if got != "hello world" {
|
||||
t.Errorf("Expected 'hello world', got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty slice", func(t *testing.T) {
|
||||
if got := Bytes2string([]byte{}); got != "" {
|
||||
t.Errorf("Expected empty string, got %q", got)
|
||||
}
|
||||
if got := Bytes2string(nil); got != "" {
|
||||
t.Errorf("Expected empty string for nil, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUniqueSliceElementsNonEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "Duplicates and empty strings",
|
||||
input: []string{"a", "b", "", "a", "c", "b", " "},
|
||||
want: []string{"a", "b", "c", " "}, // Пробел не пустая строка
|
||||
},
|
||||
{
|
||||
name: "All empty",
|
||||
input: []string{"", "", ""},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "Already unique",
|
||||
input: []string{"one", "two"},
|
||||
want: []string{"one", "two"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := UniqueSliceElementsNonEmpty(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("Length mismatch: got %d, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("At index %d: got %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user