Initial commit

This commit is contained in:
2026-04-13 17:22:26 +03:00
commit 69884bc94b
3 changed files with 616 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Binaries and builds
/builds/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
config.json
# Dependency directories
vendor/
node_modules/
# Go workspace files
*.mod
*.sum
go.work
go.work.sum
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# OS files
Thumbs.db
desktop.ini
# Logs
*.log
logs/
# Temporary files
tmp/
temp/
*.tmp
*.service
*.pid
# Coverage files
*.cover
*.coverage
coverage.html

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# ric930-fake-smtp
Fake SMTP сервер для ИВ КонсультантПлюс. Принимает любые соединения с именем пользователя и паролем, сохраняет письма в формате `.eml` с иерархической структурой папок.
## Особенности
- ✅ RFC 5321 (SMTP протокол)
- ✅ Работа как systemd сервис на Linux и как Windows Service
## Требования
- Go 1.21 или выше
## Сборка из исходников
### 1. Клонирование репозитория
```bash
https://git.stelm.me/ric930/ric930-fake-smtp.git
cd ric930-fake-smtp
go mod init ric930-fake-smtp
go get github.com/kardianos/service
### 2. Сборка бинарников
```bash
GOOS=linux GOARCH=amd64 go build -o ric930-fake-smtp
или
GOOS=windows GOARCH=amd64 go build -o ric930-fake-smtp.exe
### 3. Запуск и установка в режиме службы
Запуск в интерактивном режиме:
```bash
./ric930-fake-smtp или ./ric930-fake-smtp.exe (для Windows)
Установка в качестве systemd юнита:
```bash
./ric930-fake-smtp install
sudo systemctl daemon-reload
sudo systemctl status ric930-fake-smtp
sudo systemctl --enable-now ric930-fake-smtp
sudo journalctl -fu ric930-fake-smtp
Установка в качестве службы в Windows:
```bash
./ric930-fake-smtp.exe install
### 4. Файл конфигурации
config.json находится рядом с бинарным файлом или указывается в параметре --config path/to/config.json
Секции файла не требуют особых пояснений и интуетивно понятные

499
main.go Normal file
View File

@@ -0,0 +1,499 @@
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/kardianos/service"
)
type Config struct {
ListenAddress string `json:"listen_address"`
ListenPort int `json:"listen_port"`
StorageDir string `json:"storage_dir"`
DomainName string `json:"domain_name"`
MaxMessageSize int64 `json:"max_message_size"`
EnableAuth bool `json:"enable_auth"`
}
type SMTPState struct {
heloName string
from string
to []string
authenticated bool
mailFromReceived bool
authType string
authStep int // for LOGIN: 0=waiting username, 1=waiting password
}
type FakeSMTPServer struct {
config Config
logger service.Logger
}
type Program struct {
server *FakeSMTPServer
}
func getExecutableDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(exe), nil
}
func loadConfig(configPath string) (Config, error) {
var cfg Config
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
cfg = Config{
ListenAddress: "0.0.0.0",
ListenPort: 25,
StorageDir: "emails",
DomainName: "m.ric930.ru",
MaxMessageSize: 50 * 1024 * 1024, // 50MB
EnableAuth: true,
}
cfgData, _ := json.MarshalIndent(cfg, "", " ")
os.WriteFile(configPath, cfgData, 0644)
log.Printf("Created default config at %s", configPath)
return cfg, nil
}
return cfg, err
}
err = json.Unmarshal(data, &cfg)
return cfg, err
}
func (s *FakeSMTPServer) ensureStorageDir() error {
return os.MkdirAll(s.config.StorageDir, 0755)
}
func (s *FakeSMTPServer) getEmailFilePath(from string, to []string, receivedTime time.Time) (string, error) {
yearMonthDay := receivedTime.Format("2006-01-02")
dir := filepath.Join(s.config.StorageDir, yearMonthDay)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
fromClean := strings.ReplaceAll(from, "<", "")
fromClean = strings.ReplaceAll(fromClean, ">", "")
fromClean = strings.ReplaceAll(fromClean, "@", "_")
if len(fromClean) > 30 {
fromClean = fromClean[:30]
}
timestamp := receivedTime.Format("20060102_150405")
filename := fmt.Sprintf("%s_%s.eml", timestamp, fromClean)
return filepath.Join(dir, filename), nil
}
func (s *FakeSMTPServer) saveEmail(from string, to []string, data []byte, receivedTime time.Time) error {
filePath, err := s.getEmailFilePath(from, to, receivedTime)
if err != nil {
return err
}
receivedHeader := fmt.Sprintf("Received: from %s\r\n\tby %s (ric930-fake-smtp)\r\n\tfor <%s>;\r\n\t%s\r\n",
s.config.DomainName, s.config.DomainName, strings.Join(to, ","), receivedTime.Format(time.RFC1123Z))
finalData := []byte(receivedHeader + string(data))
return os.WriteFile(filePath, finalData, 0644)
}
func (s *FakeSMTPServer) sendReply(writer *bufio.Writer, code int, message string) {
response := fmt.Sprintf("%d %s\r\n", code, message)
writer.WriteString(response)
writer.Flush()
s.logger.Infof("S: %d %s", code, message)
}
func parseAddress(arg string) (string, error) {
start := strings.Index(arg, ":")
if start == -1 {
return "", fmt.Errorf("invalid address format")
}
addr := strings.TrimSpace(arg[start+1:])
addr = strings.Trim(addr, "<>")
if !strings.Contains(addr, "@") {
return "", fmt.Errorf("invalid email address")
}
return addr, nil
}
func (s *FakeSMTPServer) handleConnection(conn net.Conn) {
defer conn.Close()
state := &SMTPState{
to: make([]string, 0),
authenticated: !s.config.EnableAuth,
mailFromReceived: false,
authStep: 0,
}
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
s.sendReply(writer, 220, fmt.Sprintf("%s ESMTP ric930-fake-smtp", s.config.DomainName))
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
s.logger.Warningf("Read error: %v", err)
}
break
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
continue
}
s.logger.Infof("C: %s", line)
var cmd string
var arg string
if idx := strings.Index(line, " "); idx != -1 {
cmd = strings.ToUpper(line[:idx])
arg = strings.TrimSpace(line[idx+1:])
} else {
cmd = strings.ToUpper(line)
arg = ""
}
switch cmd {
case "EHLO":
if state.heloName != "" {
s.sendReply(writer, 503, "Bad sequence")
continue
}
state.heloName = arg
fmt.Fprintf(writer, "250-%s Hello %s\r\n", s.config.DomainName, arg)
fmt.Fprintf(writer, "250-PIPELINING\r\n")
fmt.Fprintf(writer, "250-SIZE %d\r\n", s.config.MaxMessageSize)
if s.config.EnableAuth {
// libcurl ожидает именно AUTH PLAIN LOGIN
fmt.Fprintf(writer, "250-AUTH PLAIN LOGIN\r\n")
}
fmt.Fprintf(writer, "250-8BITMIME\r\n")
fmt.Fprintf(writer, "250 SMTPUTF8\r\n")
writer.Flush()
case "HELO":
if state.heloName != "" {
s.sendReply(writer, 503, "Bad sequence")
continue
}
state.heloName = arg
s.sendReply(writer, 250, fmt.Sprintf("%s Hello %s", s.config.DomainName, arg))
case "AUTH":
if !s.config.EnableAuth {
s.logger.Infof("AUTH ignored because enable_auth=false")
state.authenticated = true
s.sendReply(writer, 235, "Authentication successful (bypass)")
continue
}
parts := strings.SplitN(arg, " ", 2)
method := strings.ToUpper(parts[0])
switch method {
case "PLAIN":
var authResp string
if len(parts) == 2 {
authResp = parts[1]
} else {
s.sendReply(writer, 334, "")
respLine, err := reader.ReadString('\n')
if err != nil {
s.sendReply(writer, 501, "Auth failed")
continue
}
authResp = strings.TrimSpace(respLine)
s.logger.Infof("C: %s", authResp)
}
decoded, err := base64.StdEncoding.DecodeString(authResp)
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
authParts := strings.Split(string(decoded), "\x00")
if len(authParts) >= 3 {
username := authParts[1]
password := authParts[2]
s.logger.Infof("AUTH PLAIN - Username: %s, Password: %s", username, password)
}
state.authenticated = true
s.sendReply(writer, 235, "Authentication successful")
case "LOGIN":
state.authType = "LOGIN"
state.authStep = 0
s.sendReply(writer, 334, "VXNlcm5hbWU6") // "Username:" in base64
usernameLine, err := reader.ReadString('\n')
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
usernameLine = strings.TrimSpace(usernameLine)
s.logger.Infof("C: %s", usernameLine)
usernameBytes, err := base64.StdEncoding.DecodeString(usernameLine)
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
username := string(usernameBytes)
s.sendReply(writer, 334, "UGFzc3dvcmQ6") // "Password:" in base64
passwordLine, err := reader.ReadString('\n')
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
passwordLine = strings.TrimSpace(passwordLine)
s.logger.Infof("C: %s", passwordLine)
passwordBytes, err := base64.StdEncoding.DecodeString(passwordLine)
if err != nil {
s.sendReply(writer, 535, "Auth failed")
continue
}
password := string(passwordBytes)
s.logger.Infof("AUTH LOGIN - Username: %s, Password: %s (accepted)", username, password)
state.authenticated = true
s.sendReply(writer, 235, "Authentication successful")
default:
s.sendReply(writer, 504, "Unsupported auth mechanism")
}
case "MAIL":
if !state.authenticated && s.config.EnableAuth {
s.sendReply(writer, 530, "Authentication required")
continue
}
if !strings.HasPrefix(strings.ToUpper(arg), "FROM:") {
s.sendReply(writer, 501, "Syntax error")
continue
}
from, err := parseAddress(arg)
if err != nil {
s.sendReply(writer, 501, "Invalid address")
continue
}
state.from = from
state.mailFromReceived = true
state.to = make([]string, 0)
s.sendReply(writer, 250, "OK")
case "RCPT":
if !state.mailFromReceived {
s.sendReply(writer, 503, "MAIL FROM required first")
continue
}
if !strings.HasPrefix(strings.ToUpper(arg), "TO:") {
s.sendReply(writer, 501, "Syntax error")
continue
}
to, err := parseAddress(arg)
if err != nil {
s.sendReply(writer, 501, "Invalid address")
continue
}
state.to = append(state.to, to)
s.sendReply(writer, 250, "OK")
case "DATA":
if !state.mailFromReceived || len(state.to) == 0 {
s.sendReply(writer, 503, "MAIL and RCPT required")
continue
}
s.sendReply(writer, 354, "Start mail input; end with <CRLF>.<CRLF>")
var messageBody strings.Builder
var dotEncountered bool
for {
line, err := reader.ReadString('\n')
if err != nil {
s.logger.Errorf("Error reading body: %v", err)
break
}
if strings.TrimRight(line, "\r\n") == "." {
dotEncountered = true
break
}
if len(line) > 0 && line[0] == '.' {
line = line[1:]
}
messageBody.WriteString(line)
}
if !dotEncountered {
s.sendReply(writer, 554, "No termination dot")
continue
}
receivedTime := time.Now()
if err := s.saveEmail(state.from, state.to, []byte(messageBody.String()), receivedTime); err != nil {
s.logger.Errorf("Save failed: %v", err)
s.sendReply(writer, 554, "Transaction failed")
} else {
s.logger.Infof("Email saved: %d bytes", messageBody.Len())
// КРИТИЧЕСКИ ВАЖНО для libcurl - отправляем 250
s.sendReply(writer, 250, "OK: message accepted")
}
state.mailFromReceived = false
state.from = ""
state.to = make([]string, 0)
case "RSET":
state.mailFromReceived = false
state.from = ""
state.to = make([]string, 0)
s.sendReply(writer, 250, "OK")
case "NOOP":
s.sendReply(writer, 250, "OK")
case "QUIT":
s.sendReply(writer, 221, "Bye")
return
default:
s.sendReply(writer, 502, "Command not implemented")
}
}
}
func (s *FakeSMTPServer) Start() error {
if err := s.ensureStorageDir(); err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
s.logger.Infof("SMTP server listening on %s", addr)
s.logger.Infof("Storage: %s", s.config.StorageDir)
s.logger.Infof("Auth enabled: %v", s.config.EnableAuth)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
s.logger.Errorf("Accept error: %v", err)
continue
}
go s.handleConnection(conn)
}
}()
return nil
}
func (p *Program) Start(s service.Service) error {
go p.server.Start()
return nil
}
func (p *Program) Stop(s service.Service) error {
return nil
}
func main() {
exeDir, err := getExecutableDir()
if err != nil {
log.Printf("Warning: cannot get executable directory: %v, using current directory", err)
exeDir = "."
}
defaultConfigPath := filepath.Join(exeDir, "config.json")
configPath := flag.String("config", defaultConfigPath, "Path to config file")
flag.Parse()
finalConfigPath := *configPath
if !filepath.IsAbs(finalConfigPath) {
finalConfigPath = filepath.Join(exeDir, finalConfigPath)
}
cfg, err := loadConfig(finalConfigPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if !filepath.IsAbs(cfg.StorageDir) {
cfg.StorageDir = filepath.Join(exeDir, cfg.StorageDir)
}
svcConfig := &service.Config{
Name: "ric930-fake-smtp",
DisplayName: "Ric930 Fake SMTP Server",
Description: "RIC930 Fake SMTP server for Consultant IV",
}
server := &FakeSMTPServer{config: cfg}
prg := &Program{server: server}
s, err := service.New(prg, svcConfig)
if err != nil {
log.Fatal(err)
}
logger, err := s.Logger(nil)
if err != nil {
log.Fatal(err)
}
server.logger = logger
if len(os.Args) > 1 {
service.Control(s, os.Args[1])
return
}
s.Run()
}