From 69884bc94bd7fc621e8cc069ef4a0a499c6d55aa Mon Sep 17 00:00:00 2001 From: astelm Date: Mon, 13 Apr 2026 17:22:26 +0300 Subject: [PATCH] Initial commit --- .gitignore | 50 ++++++ README.md | 67 +++++++ main.go | 499 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 616 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40d25e7 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7d861c --- /dev/null +++ b/README.md @@ -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 +Секции файла не требуют особых пояснений и интуетивно понятные diff --git a/main.go b/main.go new file mode 100644 index 0000000..43c62d3 --- /dev/null +++ b/main.go @@ -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 .") + + 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() +}