Neues Paket mail/internal/protoguard kapselt die für IMAP- und POP3-Sessions gemeinsam benötigte Timeout- und Backoff-Logik einer einzelnen Verbindung: - Pro Protokollphase konfigurierbarer Idle-Read-Timeout (POP3: Authorization/Transaction, IMAP: NotAuthenticated/Selected), vor jedem Lesevorgang neu gesetzt. - Sich verdoppelnder Backoff bei wiederholten Anmeldefehlversuchen einer Verbindung (BackoffBase bis BackoffMax), Verbindungstrennung nach konfigurierbarer Höchstzahl statt Dauerschleife. Server.NewServer bleibt unverändert (Standardkonfiguration); NewServerWithGuardConfig erlaubt abweichende Werte. Ressourcenaufräumung bei Verbindungsabbruch war bereits durch defer conn.Close() strukturell gegeben — der Timeout sorgt dafür, dass dieser Pfad auch bei hängenden oder böswilligen Gegenstellen zuverlässig erreicht wird. Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt: Chaos-Test mit 30 hart gekappten Verbindungen während aktiver Übertragung (kein Goroutine-Leck), Timeout-Auslösung in jeder Protokollphase beider Server, steigender Backoff mit definierter Verbindungstrennung nach Höchstzahl an Fehlversuchen. go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete) regressionsfrei getestet.
211 lines
6.3 KiB
Go
211 lines
6.3 KiB
Go
package pop3
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// genericAuthFailure ist bewusst IMMER derselbe Text, unabhängig davon,
|
|
// ob der Benutzername unbekannt oder nur das Passwort falsch war
|
|
// (Akzeptanzkriterium 3: fehlerhafte Anmeldeversuche ohne
|
|
// Informationspreisgabe).
|
|
const genericAuthFailure = "authentication failed"
|
|
|
|
func (s *Session) handleUser(cmd command) bool {
|
|
if s.state != Authorization {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
if len(cmd.Args) != 1 {
|
|
return writeErr(s.writer, "USER requires a username") == nil
|
|
}
|
|
// RFC 1939: USER antwortet immer mit +OK, unabhängig davon, ob der
|
|
// Name existiert — die eigentliche Prüfung passiert erst bei PASS
|
|
// (Akzeptanzkriterium 3: keine Informationspreisgabe schon an dieser
|
|
// Stelle).
|
|
s.pendingUsername = cmd.Args[0]
|
|
return writeOK(s.writer, "send PASS") == nil
|
|
}
|
|
|
|
func (s *Session) handlePass(ctx context.Context, cmd command) bool {
|
|
if s.state != Authorization {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
if s.pendingUsername == "" {
|
|
return writeErr(s.writer, genericAuthFailure) == nil
|
|
}
|
|
if len(cmd.Args) != 1 {
|
|
return writeErr(s.writer, "PASS requires a password") == nil
|
|
}
|
|
if s.auth == nil {
|
|
return writeErr(s.writer, genericAuthFailure) == nil
|
|
}
|
|
|
|
ok, err := s.auth.Authenticate(ctx, s.pendingUsername, cmd.Args[0])
|
|
if err != nil || !ok {
|
|
// Backoff statt Dauerschleife bei wiederholten Fehlversuchen
|
|
// (Akzeptanzkriterium 3, ING-07). Immer derselbe generische Text,
|
|
// egal ob unbekannter Nutzer, falsches Passwort oder interner
|
|
// Fehler.
|
|
backoff, disconnect := s.guard.RecordAuthFailure()
|
|
s.guard.Wait(ctx, backoff)
|
|
if err := writeErr(s.writer, genericAuthFailure); err != nil {
|
|
return false
|
|
}
|
|
return !disconnect
|
|
}
|
|
|
|
s.guard.ResetAuthFailures()
|
|
s.username = s.pendingUsername
|
|
s.state = Transaction
|
|
return writeOK(s.writer, "maildrop locked and ready") == nil
|
|
}
|
|
|
|
func (s *Session) handleStat(ctx context.Context) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
messages, err := s.activeMessages(ctx)
|
|
if err != nil {
|
|
return writeErr(s.writer, "unable to read maildrop") == nil
|
|
}
|
|
var totalSize int64
|
|
for _, m := range messages {
|
|
totalSize += m.Size
|
|
}
|
|
return writeOK(s.writer, fmt.Sprintf("%d %d", len(messages), totalSize)) == nil
|
|
}
|
|
|
|
func (s *Session) handleList(ctx context.Context, cmd command) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
messages, err := s.activeMessages(ctx)
|
|
if err != nil {
|
|
return writeErr(s.writer, "unable to read maildrop") == nil
|
|
}
|
|
|
|
if len(cmd.Args) == 1 {
|
|
n, convErr := strconv.Atoi(cmd.Args[0])
|
|
if convErr != nil {
|
|
return writeErr(s.writer, "invalid message number") == nil
|
|
}
|
|
for _, m := range messages {
|
|
if m.Number == n {
|
|
return writeOK(s.writer, fmt.Sprintf("%d %d", m.Number, m.Size)) == nil
|
|
}
|
|
}
|
|
return writeErr(s.writer, "no such message") == nil
|
|
}
|
|
|
|
var totalSize int64
|
|
lines := make([]string, 0, len(messages))
|
|
for _, m := range messages {
|
|
totalSize += m.Size
|
|
lines = append(lines, fmt.Sprintf("%d %d", m.Number, m.Size))
|
|
}
|
|
return writeMultiline(s.writer, fmt.Sprintf("%d messages (%d octets)", len(messages), totalSize), strings.Join(lines, "\n")) == nil
|
|
}
|
|
|
|
func (s *Session) handleRetr(ctx context.Context, cmd command) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
n, err := s.parseActiveMessageNumber(ctx, cmd)
|
|
if err != nil {
|
|
return writeErr(s.writer, err.Error()) == nil
|
|
}
|
|
|
|
content, err := s.store.Retrieve(ctx, s.username, n)
|
|
if err != nil {
|
|
return writeErr(s.writer, "unable to retrieve message") == nil
|
|
}
|
|
// Akzeptanzkriterium 2: RETR liefert die VOLLSTÄNDIGE Nachricht.
|
|
return writeMultiline(s.writer, fmt.Sprintf("%d octets", len(content)), string(content)) == nil
|
|
}
|
|
|
|
func (s *Session) handleDele(cmd command) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
if len(cmd.Args) != 1 {
|
|
return writeErr(s.writer, "DELE requires a message number") == nil
|
|
}
|
|
n, err := strconv.Atoi(cmd.Args[0])
|
|
if err != nil {
|
|
return writeErr(s.writer, "invalid message number") == nil
|
|
}
|
|
if s.deleted[n] {
|
|
return writeErr(s.writer, "message already deleted") == nil
|
|
}
|
|
// NUR innerhalb der Sitzung markiert — endgültig gelöscht wird
|
|
// ausschließlich in handleQuit (Akzeptanzkriterium 2/Pflichtprüfung 3).
|
|
s.deleted[n] = true
|
|
return writeOK(s.writer, fmt.Sprintf("message %d deleted", n)) == nil
|
|
}
|
|
|
|
func (s *Session) handleQuit(ctx context.Context) bool {
|
|
if s.state != Transaction {
|
|
// Aus Authorization: keine Update-Phase, keine Löschungen möglich
|
|
// (es wurde noch nichts markiert).
|
|
_ = writeOK(s.writer, "goodbye")
|
|
return false
|
|
}
|
|
|
|
s.state = Update
|
|
if len(s.deleted) > 0 {
|
|
numbers := make([]int, 0, len(s.deleted))
|
|
for n := range s.deleted {
|
|
numbers = append(numbers, n)
|
|
}
|
|
if err := s.store.Delete(ctx, s.username, numbers); err != nil {
|
|
_ = writeErr(s.writer, "unable to update maildrop, changes not committed")
|
|
return false
|
|
}
|
|
}
|
|
_ = writeOK(s.writer, "goodbye")
|
|
return false
|
|
}
|
|
|
|
// activeMessages liefert alle Nachrichten, die in DIESER Sitzung noch
|
|
// nicht per DELE markiert wurden (RFC 1939: gelöschte Nachrichten sind
|
|
// für STAT/LIST/RETR ab dem Zeitpunkt der Markierung nicht mehr sichtbar,
|
|
// auch wenn die Löschung selbst erst bei QUIT endgültig wird).
|
|
func (s *Session) activeMessages(ctx context.Context) ([]Message, error) {
|
|
all, err := s.store.List(ctx, s.username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
active := make([]Message, 0, len(all))
|
|
for _, m := range all {
|
|
if !s.deleted[m.Number] {
|
|
active = append(active, m)
|
|
}
|
|
}
|
|
return active, nil
|
|
}
|
|
|
|
func (s *Session) parseActiveMessageNumber(ctx context.Context, cmd command) (int, error) {
|
|
if len(cmd.Args) != 1 {
|
|
return 0, fmt.Errorf("requires a message number")
|
|
}
|
|
n, err := strconv.Atoi(cmd.Args[0])
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid message number")
|
|
}
|
|
if s.deleted[n] {
|
|
return 0, fmt.Errorf("message deleted")
|
|
}
|
|
messages, err := s.activeMessages(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("unable to read maildrop")
|
|
}
|
|
for _, m := range messages {
|
|
if m.Number == n {
|
|
return n, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("no such message")
|
|
}
|