feat: 新增 SMTP/IMAP/POP3 协议调用日志(含攻击分析筛选)
- 每个连接记录一条日志:协议、端口、来源 IP、用户名、成功/失败、 失败原因(密码错误/IP封禁/中继被拒/发件人伪造/未认证发信等)、 操作摘要、消息数与会话时长 - 管理后台新增「协议日志」页:按协议/状态/IP/用户名/时间筛选, 今日与历史成功/失败统计卡片,分页查看,可手动清理 - 后台每 6 小时自动清理超出 protocol_log_keep_days(默认30天) 的日志;新增 [web] protocol_log_keep_days 配置项 - 修复 POP3 认证既有 bug:handleUSER 丢弃邮箱域名导致 PASS 永远失败 - 新增 store 单测、SMTP/POP3 端到端测试与模板渲染测试
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -113,9 +115,31 @@ func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||
mode: be.mode,
|
||||
rcpts: make([]string, 0),
|
||||
clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()),
|
||||
startedAt: time.Now(),
|
||||
port: be.server.sessionPort(be.mode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sessionPort 返回该会话监听的端口号(区分明文/TLS/提交端口),解析失败返回 0。
|
||||
func (s *SMTPServer) sessionPort(mode smtpMode) int {
|
||||
addr := s.cfg.Addr
|
||||
switch mode {
|
||||
case smtpModeSubmission:
|
||||
addr = s.cfg.SubmissionAddr
|
||||
case smtpModeImplicitTLS:
|
||||
addr = s.cfg.TLSAddr
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
// smtpSession implements the smtp.Session interface for handling a single connection.
|
||||
type smtpSession struct {
|
||||
backend *smtpBackend
|
||||
@@ -129,6 +153,16 @@ type smtpSession struct {
|
||||
email string
|
||||
user *db.User
|
||||
clientIP string
|
||||
|
||||
// 会话日志累积状态
|
||||
startedAt time.Time
|
||||
port int
|
||||
authTried bool
|
||||
authOK bool
|
||||
authUsername string
|
||||
failReason string // 首个失败原因
|
||||
msgCount int // 成功处理的邮件数(本地投递 + 外发队列)
|
||||
detailParts []string
|
||||
}
|
||||
|
||||
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
||||
@@ -136,14 +170,30 @@ func (s *smtpSession) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain}
|
||||
}
|
||||
|
||||
// recordFail 记录会话中第一个失败原因(日志用途,不改变协议行为)。
|
||||
func (s *smtpSession) recordFail(reason string) {
|
||||
if s.failReason == "" {
|
||||
s.failReason = reason
|
||||
}
|
||||
}
|
||||
|
||||
// recordDetail 追加一条操作摘要。
|
||||
func (s *smtpSession) recordDetail(part string) {
|
||||
s.detailParts = append(s.detailParts, part)
|
||||
}
|
||||
|
||||
// Auth authenticates the user with SASL PLAIN credentials.
|
||||
func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
if mech != sasl.Plain {
|
||||
s.recordFail("不支持的认证机制")
|
||||
return nil, smtp.ErrAuthUnknownMechanism
|
||||
}
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
s.authTried = true
|
||||
s.authUsername = username
|
||||
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
|
||||
if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned {
|
||||
s.recordFail("IP已被封禁")
|
||||
return smtp.ErrAuthFailed
|
||||
}
|
||||
|
||||
@@ -155,6 +205,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
s.backend.server.banCfg.MaxFailAttempts,
|
||||
s.backend.server.banCfg.BanDurationMin,
|
||||
)
|
||||
s.recordFail("用户名或密码错误")
|
||||
return smtp.ErrAuthFailed
|
||||
}
|
||||
|
||||
@@ -166,10 +217,12 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
}
|
||||
}
|
||||
if domainName == "" {
|
||||
s.recordFail("用户名或密码错误")
|
||||
return smtp.ErrAuthFailed
|
||||
}
|
||||
|
||||
s.authenticated = true
|
||||
s.authOK = true
|
||||
s.userID = user.ID
|
||||
s.user = user
|
||||
s.email = user.Username + "@" + domainName
|
||||
@@ -180,10 +233,12 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
// Mail records the sender address (MAIL FROM command).
|
||||
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||
if s.mode != smtpModeInbound && !s.authenticated {
|
||||
s.recordFail("未认证用户尝试发信")
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
// Authenticated users may only send as themselves, preventing spoofing.
|
||||
if s.authenticated && !strings.EqualFold(strings.TrimSpace(from), s.email) {
|
||||
s.recordFail("发件人地址与登录用户不一致")
|
||||
return fmt.Errorf("sender address must match authenticated user")
|
||||
}
|
||||
|
||||
@@ -201,6 +256,7 @@ func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" {
|
||||
s.recordFail("无效的收件人地址")
|
||||
return fmt.Errorf("invalid recipient address: %s", to)
|
||||
}
|
||||
|
||||
@@ -212,12 +268,14 @@ func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
|
||||
// External recipient: only authenticated local users may relay.
|
||||
if !s.authenticated {
|
||||
s.recordFail("中继访问被拒绝")
|
||||
return fmt.Errorf("relay access denied: %s", to)
|
||||
}
|
||||
|
||||
// Sender verification must have been enforced in Mail() already.
|
||||
ob := s.backend.server.outbound
|
||||
if ob == nil || !ob.Enabled() {
|
||||
s.recordFail("外部投递未启用")
|
||||
return fmt.Errorf("external delivery is disabled: %s", to)
|
||||
}
|
||||
|
||||
@@ -235,20 +293,24 @@ func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
|
||||
// outbound delivery.
|
||||
func (s *smtpSession) Data(r io.Reader) error {
|
||||
if len(s.rcpts) == 0 {
|
||||
s.recordFail("未指定收件人")
|
||||
return fmt.Errorf("no accepted recipients")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
s.recordFail("读取邮件数据失败")
|
||||
return fmt.Errorf("failed to read message data: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := parseSMTPMessage(data)
|
||||
if err != nil {
|
||||
s.recordFail("邮件格式解析失败")
|
||||
return err
|
||||
}
|
||||
|
||||
// Local recipients: deliver to INBOX.
|
||||
localDelivered := 0
|
||||
for _, rcpt := range s.localRcpts {
|
||||
user, err := s.localUserByEmail(rcpt)
|
||||
if err != nil {
|
||||
@@ -260,25 +322,33 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
continue
|
||||
}
|
||||
log.Printf("SMTP: message delivered to %s", rcpt)
|
||||
localDelivered++
|
||||
}
|
||||
s.msgCount += localDelivered
|
||||
|
||||
// External recipients: queue for outbound delivery.
|
||||
externalQueued := 0
|
||||
if len(s.externalRcpts) > 0 {
|
||||
ob := s.backend.server.outbound
|
||||
if ob == nil {
|
||||
s.recordFail("外部投递服务不可用")
|
||||
return fmt.Errorf("outbound delivery is unavailable")
|
||||
}
|
||||
maxRcpt := ob.MaxRecipients()
|
||||
if maxRcpt > 0 && len(s.externalRcpts) > maxRcpt {
|
||||
s.recordFail("外部收件人数量超出限制")
|
||||
return fmt.Errorf("too many external recipients: %d (max %d)", len(s.externalRcpts), maxRcpt)
|
||||
}
|
||||
for _, rcpt := range s.externalRcpts {
|
||||
if _, err := ob.Enqueue(s.user, s.email, rcpt, data); err != nil {
|
||||
s.recordFail("外发队列投递失败")
|
||||
return fmt.Errorf("failed to queue external recipient %s: %v", rcpt, err)
|
||||
}
|
||||
log.Printf("SMTP: external message queued for %s", rcpt)
|
||||
externalQueued++
|
||||
}
|
||||
}
|
||||
s.msgCount += externalQueued
|
||||
|
||||
if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound {
|
||||
if err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
|
||||
@@ -286,6 +356,8 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
s.recordDetail(fmt.Sprintf("MAIL FROM:<%s> RCPT×%d 本地投递%d 外发%d",
|
||||
s.from, len(s.rcpts), localDelivered, externalQueued))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -434,5 +506,46 @@ func (s *smtpSession) Reset() {
|
||||
|
||||
// Logout is called when the SMTP connection is closed.
|
||||
func (s *smtpSession) Logout() error {
|
||||
s.writeProtocolLog()
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeProtocolLog 汇总本会话状态写入协议调用日志(供后台分析攻击/滥用)。
|
||||
func (s *smtpSession) writeProtocolLog() {
|
||||
success := s.failReason == ""
|
||||
detail := strings.Join(s.detailParts, "; ")
|
||||
username := s.authUsername
|
||||
if username == "" && s.email != "" {
|
||||
username = s.email
|
||||
}
|
||||
if detail == "" {
|
||||
if s.authTried {
|
||||
if s.authOK {
|
||||
detail = "AUTH 成功"
|
||||
} else {
|
||||
detail = "AUTH 失败"
|
||||
}
|
||||
} else if success {
|
||||
detail = "连接建立,无邮件操作"
|
||||
}
|
||||
}
|
||||
if success && s.authTried && !s.authOK {
|
||||
success = false
|
||||
}
|
||||
|
||||
entry := &db.ProtocolLog{
|
||||
Protocol: db.ProtocolSMTP,
|
||||
Port: s.port,
|
||||
ClientIP: s.clientIP,
|
||||
Username: username,
|
||||
Success: success,
|
||||
FailReason: s.failReason,
|
||||
Detail: detail,
|
||||
MsgCount: s.msgCount,
|
||||
DurationMs: time.Since(s.startedAt).Milliseconds(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := s.backend.server.stores.ProtocolLogs.Create(entry); err != nil {
|
||||
log.Printf("SMTP: 写入协议日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -63,7 +66,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil {
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
@@ -113,3 +116,99 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
|
||||
t.Fatalf("attachment content mismatch: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLoggingRecordsAuthFailure 验证认证失败的会话在 Logout 时写入协议日志。
|
||||
func TestSessionLoggingRecordsAuthFailure(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
srv := &SMTPServer{stores: stores, banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30}}
|
||||
sess := &smtpSession{
|
||||
backend: &smtpBackend{server: srv, mode: smtpModeSubmission},
|
||||
clientIP: "203.0.113.7",
|
||||
startedAt: time.Now(),
|
||||
port: 587,
|
||||
}
|
||||
|
||||
// 触发一次认证(用户名不存在 → 失败)
|
||||
mech, err := sess.Auth(sasl.Plain)
|
||||
if err != nil {
|
||||
t.Fatalf("Auth: %v", err)
|
||||
}
|
||||
// SASL PLAIN 凭据格式: authzid\0authcid\0passwd
|
||||
if _, _, err := mech.Next([]byte("\x00no-such-user\x00wrong-pass")); err == nil {
|
||||
t.Fatal("expected auth failure for unknown user")
|
||||
}
|
||||
|
||||
// 直接调用 Logout 模拟连接结束
|
||||
if err := sess.Logout(); err != nil {
|
||||
t.Fatalf("Logout: %v", err)
|
||||
}
|
||||
|
||||
logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("expected 1 log, got %d", total)
|
||||
}
|
||||
log := logs[0]
|
||||
if log.Protocol != db.ProtocolSMTP || log.Port != 587 || log.ClientIP != "203.0.113.7" {
|
||||
t.Fatalf("unexpected log: %+v", log)
|
||||
}
|
||||
if log.Success {
|
||||
t.Fatalf("expected failure, got %+v", log)
|
||||
}
|
||||
if log.FailReason == "" {
|
||||
t.Fatal("expected fail reason")
|
||||
}
|
||||
if log.Username != "no-such-user" {
|
||||
t.Fatalf("username = %q", log.Username)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLoggingRecordsDelivery 验证投递成功的会话写入成功日志。
|
||||
func TestSessionLoggingRecordsDelivery(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
attStorage := storage.NewAttachmentStorage(t.TempDir())
|
||||
srv := &SMTPServer{stores: stores, storage: attStorage}
|
||||
sess := &smtpSession{
|
||||
backend: &smtpBackend{server: srv, mode: smtpModeInbound},
|
||||
clientIP: "203.0.113.8",
|
||||
startedAt: time.Now(),
|
||||
port: 25,
|
||||
rcpts: make([]string, 0),
|
||||
}
|
||||
|
||||
if err := sess.Mail("sender@example.com", nil); err != nil {
|
||||
t.Fatalf("Mail: %v", err)
|
||||
}
|
||||
if err := sess.Logout(); err != nil {
|
||||
t.Fatalf("Logout: %v", err)
|
||||
}
|
||||
|
||||
logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("expected 1 log, got %d", total)
|
||||
}
|
||||
if !logs[0].Success {
|
||||
t.Fatalf("expected success, got %+v", logs[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user