diff --git a/mail/docs/ING-07-PRUEFPROTOKOLL.md b/mail/docs/ING-07-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..f1929f3 --- /dev/null +++ b/mail/docs/ING-07-PRUEFPROTOKOLL.md @@ -0,0 +1,100 @@ +# ING-07 — Protokoll-Fehlerbehandlung & Wiederverbindung: Prüfprotokoll + +Datum: 2026-09-01 +Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh +Pakete: `mail/internal/protoguard` (neu, gemeinsam genutzt), `mail/internal/imap`, `mail/internal/pop3` + +## Umsetzung + +Neues Paket `protoguard` kapselt Timeout- und Backoff-Logik EINER +Verbindung (`Guard`), von IMAP- und POP3-Session gleichermaßen genutzt: + +- `ApplyReadDeadline(conn, phase)` setzt vor jedem Lesevorgang die + Lese-Deadline passend zur aktuellen Protokollphase (POP3: + Authorization/Transaction, IMAP: NotAuthenticated/Selected). +- `RecordAuthFailure()` zählt Anmeldefehlversuche EINER Verbindung, + liefert eine sich verdoppelnde Backoff-Wartezeit (`BackoffBase` bis + `BackoffMax`) und meldet nach `MaxAuthFailures`, dass die Verbindung + zu trennen ist. + +`Server.NewServer` verwendet `protoguard.DefaultConfig()` (5 Minuten +Timeout, max. 5 Fehlversuche, 200ms–5s Backoff); `NewServerWithGuardConfig` +erlaubt abweichende Werte für Tests/gehärtete Umgebungen. Bestehende +Aufrufer von `NewServer(auth, store)` sind unverändert kompatibel. + +Ressourcenaufräumung bei Verbindungsabbruch war bereits vor ING-07 +durch `defer conn.Close()` in beiden Sessions strukturell gegeben — +ING-07 sorgt dafür, dass dieser Pfad auch bei hängenden oder böswilligen +Gegenstellen zuverlässig erreicht wird (Timeout statt endlosem +Blockieren). + +## Pflichtprüfung 1: Chaos-Test — harter Verbindungsabbruch während aktiver Übertragung, kein Ressourcenleck + +`TestGuard_ChaosHardCutDuringTransferNoLeak` (`pop3/guard_test.go`, +`imap/guard_test.go`): 30 reale TCP-Verbindungen, jeweils angemeldet und +mitten in einer laufenden Anfrage (POP3: RETR-Kopfzeile gelesen, Rest +nicht konsumiert; IMAP: FETCH gesendet, Antwort nicht abgewartet) hart +per `conn.Close()` gekappt. `runtime.NumGoroutine()` vor und nach den 30 +Abbrüchen verglichen (mit Toleranz für Laufzeit-Jitter und Wartezeit für +Server-Aufräumung). + +Ergebnis: **BESTANDEN** — Goroutinezahl kehrt in beiden Paketen auf den +Ausgangswert zurück, kein Leck. + +## Pflichtprüfung 2: Test für Timeout-Auslösung in jeder Protokollphase + +`TestGuard_TimeoutPerPhase` (beide Pakete), Guard mit 100ms Timeout je +Phase konfiguriert: + +- POP3: Subtest `authorization` (Verbindung offen, nichts gesendet) und + `transaction` (nach erfolgreichem USER/PASS nichts weiter gesendet) — + beide erwarten Verbindungsende durch Timeout. +- IMAP: Subtest `not_authenticated` und `selected` (nach LOGIN+SELECT) + — gleiche Erwartung. + +Ergebnis: **BESTANDEN** — alle vier Subtests bestätigen, dass der +konfigurierte Timeout in der jeweiligen Phase tatsächlich greift. + +## Pflichtprüfung 3: Test für Backoff-Verhalten bei wiederholten Fehlversuchen + +`TestGuard_BackoffOnRepeatedAuthFailures` (beide Pakete), Guard mit +`MaxAuthFailures=3`, `BackoffBase=50ms`, `BackoffMax=500ms`: + +- Drei aufeinanderfolgende fehlgeschlagene Anmeldeversuche (POP3: + USER+PASS falsch; IMAP: LOGIN falsch) über dieselbe Verbindung. + Gemessene Antwortzeit des zweiten Versuchs ist länger als die des + ersten (Verdopplung statt konstanter oder fehlender Wartezeit). +- Nach dem dritten (= `MaxAuthFailures`-ten) Fehlversuch wird die + Verbindung serverseitig getrennt — ein weiterer Anmeldeversuch über + dieselbe Verbindung schlägt fehl statt in einer Dauerschleife erneut + beantwortet zu werden. + +Ergebnis: **BESTANDEN**. + +## Akzeptanzkriterien + +1. **Verbindungsabbrüche räumen serverseitige Session-Ressourcen + zuverlässig auf**: durch Pflichtprüfung 1 belegt (kein + Goroutine-Leck nach 30 harten Abbrüchen in beiden Protokollen). +2. **Timeouts sind pro Protokollphase konfigurierbar und greifen + nachweislich**: durch Pflichtprüfung 2 belegt (`protoguard.Config. + PhaseTimeout` je Phase, vier bestandene Subtests). +3. **Wiederholte Fehlversuche eines Clients führen zu klar definiertem + Backoff statt Dauerschleife**: durch Pflichtprüfung 3 belegt + (steigender Backoff, definierte Trennung nach `MaxAuthFailures`). + +## Build/Vet/Lint/Test — Gesamtmodul + +``` +go build ./... → OK +go vet ./... → OK +golangci-lint run ./... → 0 issues +go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok, inkl. neuem internal/protoguard (indirekt über imap/pop3-Tests abgedeckt) +``` + +Keine Regression in den bestehenden ~24 Paketen. + +## Ergebnis + +ING-07 erfüllt alle Pflichtprüfungen und Akzeptanzkriterien mit echten, +ausgeführten Nachweisen. Freigeschaltet: QA-02. diff --git a/mail/internal/imap/commands.go b/mail/internal/imap/commands.go index f009314..714a332 100644 --- a/mail/internal/imap/commands.go +++ b/mail/internal/imap/commands.go @@ -29,12 +29,17 @@ func (s *Session) handleLogin(ctx context.Context, cmd command) bool { } ok, err := s.auth.Authenticate(ctx, cmd.Args[0], cmd.Args[1]) - if err != nil { - return s.writeErr(cmd.Tag, "NO", "LOGIN failed") - } - if !ok { - return s.writeErr(cmd.Tag, "NO", "LOGIN failed") + if err != nil || !ok { + // Backoff statt Dauerschleife bei wiederholten Fehlversuchen + // (Akzeptanzkriterium 3, ING-07). + backoff, disconnect := s.guard.RecordAuthFailure() + s.guard.Wait(ctx, backoff) + if !s.writeErr(cmd.Tag, "NO", "LOGIN failed") { + return false + } + return !disconnect } + s.guard.ResetAuthFailures() s.state = Authenticated return s.writeErr(cmd.Tag, "OK", "LOGIN completed") } diff --git a/mail/internal/imap/guard_test.go b/mail/internal/imap/guard_test.go new file mode 100644 index 0000000..75da5ca --- /dev/null +++ b/mail/internal/imap/guard_test.go @@ -0,0 +1,157 @@ +package imap + +import ( + "context" + "net" + "runtime" + "strings" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" +) + +func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (addr string, stop func()) { + t.Helper() + auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}} + store := fakeMailboxStore{mailboxes: map[string][]Message{ + "INBOX": { + {SequenceNumber: 1, UID: 101, Flags: []string{"\\Seen"}}, + {SequenceNumber: 2, UID: 102, Flags: []string{}}, + }, + }} + srv := NewServerWithGuardConfig(auth, store, guardCfg) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listener: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + _ = srv.Serve(ctx, listener) + close(done) + }() + return listener.Addr().String(), func() { + cancel() + <-done + } +} + +// TestGuard_ChaosHardCutDuringTransferNoLeak ist die geforderte +// Pflichtprüfung 1 (ING-07): Verbindung wird während aktiver +// Übertragung hart gekappt, danach kein Ressourcenleck. +func TestGuard_ChaosHardCutDuringTransferNoLeak(t *testing.T) { + addr, stop := startTestServerWithGuardConfig(t, protoguard.DefaultConfig()) + defer stop() + + runtime.GC() + baseline := runtime.NumGoroutine() + + const rounds = 30 + for i := 0; i < rounds; i++ { + c := dial(t, addr) + c.sendTagged(t, `LOGIN alice geheim123`) + c.sendTagged(t, `SELECT INBOX`) + // Mitten in einer laufenden Anfrage hart abbrechen: Kommando + // senden, aber die vollständige Antwort NICHT abwarten. + _, err := c.conn.Write([]byte("A99 FETCH 1:2 (FLAGS)\r\n")) + if err != nil { + t.Fatalf("kommando senden: %v", err) + } + _ = c.conn.Close() + } + + deadline := time.Now().Add(3 * time.Second) + for { + runtime.GC() + current := runtime.NumGoroutine() + if current <= baseline+2 { + return + } + if time.Now().After(deadline) { + t.Fatalf("goroutine-leck nach hartem Verbindungsabbruch: baseline=%d, aktuell=%d", baseline, current) + } + time.Sleep(50 * time.Millisecond) + } +} + +// TestGuard_TimeoutPerPhase ist die geforderte Pflichtprüfung 2 +// (ING-07): Timeout-Auslösung in jeder Protokollphase. +func TestGuard_TimeoutPerPhase(t *testing.T) { + cfg := protoguard.Config{ + PhaseTimeout: map[protoguard.Phase]time.Duration{ + phaseNotAuthenticated: 100 * time.Millisecond, + phaseSelected: 100 * time.Millisecond, + }, + DefaultTimeout: 5 * time.Second, + } + + t.Run("not_authenticated", func(t *testing.T) { + addr, stop := startTestServerWithGuardConfig(t, cfg) + defer stop() + c := dial(t, addr) + defer c.close() + + _ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := c.reader.ReadString('\n') + if err == nil { + t.Fatalf("erwartete Verbindungsende durch NotAuthenticated-Timeout") + } + }) + + t.Run("selected", func(t *testing.T) { + addr, stop := startTestServerWithGuardConfig(t, cfg) + defer stop() + c := dial(t, addr) + defer c.close() + c.sendTagged(t, `LOGIN alice geheim123`) + c.sendTagged(t, `SELECT INBOX`) // jetzt Selected, nichts weiter senden + + _ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err := c.reader.ReadString('\n') + if err == nil { + t.Fatalf("erwartete Verbindungsende durch Selected-Timeout") + } + }) +} + +// TestGuard_BackoffOnRepeatedAuthFailures ist die geforderte +// Pflichtprüfung 3 (ING-07): Backoff-Verhalten bei wiederholten +// Fehlversuchen statt Dauerschleife. +func TestGuard_BackoffOnRepeatedAuthFailures(t *testing.T) { + cfg := protoguard.Config{ + DefaultTimeout: 5 * time.Second, + MaxAuthFailures: 3, + BackoffBase: 50 * time.Millisecond, + BackoffMax: 500 * time.Millisecond, + } + addr, stop := startTestServerWithGuardConfig(t, cfg) + defer stop() + c := dial(t, addr) + defer c.close() + + var attemptDurations []time.Duration + for i := 0; i < 3; i++ { + start := time.Now() + _, lines := c.sendTagged(t, `LOGIN alice falsch`) + last := lines[len(lines)-1] + if !strings.Contains(last, "NO") { + t.Fatalf("fehlversuch %d: erwartete NO, habe: %q", i+1, last) + } + attemptDurations = append(attemptDurations, time.Since(start)) + } + + if attemptDurations[1] <= attemptDurations[0] { + t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations) + } + + // Nach MaxAuthFailures muss die Verbindung getrennt sein. + _ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := c.conn.Write([]byte("A99 LOGIN alice geheim123\r\n")); err == nil { + _, err = c.reader.ReadString('\n') + if err == nil { + t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures) + } + } +} diff --git a/mail/internal/imap/server.go b/mail/internal/imap/server.go index 2fa90ad..5bad0d9 100644 --- a/mail/internal/imap/server.go +++ b/mail/internal/imap/server.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" ) // Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen @@ -13,12 +15,20 @@ import ( // Klartext-TCP, wie im Ticket vorgesehen ("Bereite höchstens die // Schnittstelle dafür vor"). type Server struct { - auth Authenticator - store MailboxStore + auth Authenticator + store MailboxStore + guardCfg protoguard.Config } func NewServer(auth Authenticator, store MailboxStore) *Server { - return &Server{auth: auth, store: store} + return NewServerWithGuardConfig(auth, store, protoguard.DefaultConfig()) +} + +// NewServerWithGuardConfig erlaubt abweichende Phase-Timeouts und +// Backoff-Parameter (ING-07), z. B. für Tests oder gehärtete +// Betriebsumgebungen. +func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Server { + return &Server{auth: auth, store: store, guardCfg: guardCfg} } // Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder @@ -41,7 +51,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error { } return fmt.Errorf("imap: verbindung annehmen: %w", err) } - session := newSession(conn, srv.auth, srv.store) + session := newSession(conn, srv.auth, srv.store, srv.guardCfg) go session.Serve(ctx) } } diff --git a/mail/internal/imap/session.go b/mail/internal/imap/session.go index dcd220f..0b30b3d 100644 --- a/mail/internal/imap/session.go +++ b/mail/internal/imap/session.go @@ -7,6 +7,18 @@ import ( "io" "net" "strings" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" +) + +// phaseNotAuthenticated/phaseSelected sind die protoguard-Phasen dieser +// Sitzung (ING-07 Akzeptanzkriterium 2: Timeouts pro Protokollphase +// konfigurierbar). Authenticated und Selected teilen sich denselben +// Timeout — beides ist bereits angemeldeter Zustand, nur die +// Anmeldephase braucht separate (typischerweise kürzere) Werte. +const ( + phaseNotAuthenticated protoguard.Phase = "not_authenticated" + phaseSelected protoguard.Phase = "selected" ) // maxCommandLineBytes begrenzt eine einzelne Kommandozeile (Defensive @@ -18,27 +30,37 @@ const maxCommandLineBytes = 8192 // Session ist eine einzelne IMAP-Verbindung mit eigener // Zustandsmaschine (Akzeptanzkriterium 1). type Session struct { - conn net.Conn - reader *bufio.Reader - writer *bufio.Writer - auth Authenticator - store MailboxStore + conn net.Conn + reader *bufio.Reader + writer *bufio.Writer + auth Authenticator + store MailboxStore + guard *protoguard.Guard state State mailbox string // gewähltes Postfach im Zustand Selected mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT } -func newSession(conn net.Conn, auth Authenticator, store MailboxStore) *Session { +func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Session { return &Session{ conn: conn, reader: bufio.NewReaderSize(conn, maxCommandLineBytes), writer: bufio.NewWriter(conn), auth: auth, store: store, + guard: protoguard.New(guardCfg), state: NotAuthenticated, } } +// currentPhase liefert die protoguard-Phase des aktuellen Sitzungszustands. +func (s *Session) currentPhase() protoguard.Phase { + if s.state == NotAuthenticated { + return phaseNotAuthenticated + } + return phaseSelected +} + // State liefert den aktuellen Sitzungszustand (für Tests). func (s *Session) State() State { return s.state } @@ -51,8 +73,18 @@ func (s *Session) Serve(ctx context.Context) { } for { + // Akzeptanzkriterium 2 (ING-07): Idle-Timeout pro Protokollphase, + // vor jedem Lesevorgang neu gesetzt, da ein Zustandswechsel die + // Phase (und damit den geltenden Timeout) ändern kann. + if err := s.guard.ApplyReadDeadline(s.conn, s.currentPhase()); err != nil { + return + } line, err := s.readLine() if err != nil { + // Verbindungsende (Timeout, Netzwerkabbruch oder harter + // Abbruch) — Session-Ressourcen werden über das defer + // conn.Close() oben zuverlässig freigegeben + // (Akzeptanzkriterium 1). return } if line == "" { diff --git a/mail/internal/pop3/commands.go b/mail/internal/pop3/commands.go index ab99e31..d908e0f 100644 --- a/mail/internal/pop3/commands.go +++ b/mail/internal/pop3/commands.go @@ -44,11 +44,19 @@ func (s *Session) handlePass(ctx context.Context, cmd command) bool { ok, err := s.auth.Authenticate(ctx, s.pendingUsername, cmd.Args[0]) if err != nil || !ok { - // Immer derselbe generische Text, egal ob unbekannter Nutzer, - // falsches Passwort oder interner Fehler (Akzeptanzkriterium 3). - return writeErr(s.writer, genericAuthFailure) == nil + // 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 diff --git a/mail/internal/pop3/guard_test.go b/mail/internal/pop3/guard_test.go new file mode 100644 index 0000000..54bbab8 --- /dev/null +++ b/mail/internal/pop3/guard_test.go @@ -0,0 +1,189 @@ +package pop3 + +import ( + "bufio" + "context" + "net" + "runtime" + "strings" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" +) + +func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (addr string, store *fakeMailboxStore, stop func()) { + t.Helper() + auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}} + store = newFakeMailboxStore() + srv := NewServerWithGuardConfig(auth, store, guardCfg) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listener: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + _ = srv.Serve(ctx, listener) + close(done) + }() + return listener.Addr().String(), store, func() { + cancel() + <-done + } +} + +// TestGuard_ChaosHardCutDuringTransferNoLeak ist die geforderte +// Pflichtprüfung 1 (ING-07): Verbindung wird während aktiver +// Übertragung hart gekappt, danach kein Ressourcenleck. +func TestGuard_ChaosHardCutDuringTransferNoLeak(t *testing.T) { + addr, _, stop := startTestServerWithGuardConfig(t, protoguard.DefaultConfig()) + defer stop() + + runtime.GC() + baseline := runtime.NumGoroutine() + + const rounds = 30 + for i := 0; i < rounds; i++ { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + reader := bufio.NewReader(conn) + _, _ = reader.ReadString('\n') // Begrüßung + _, _ = conn.Write([]byte("USER alice\r\n")) + _, _ = reader.ReadString('\n') + _, _ = conn.Write([]byte("PASS geheim123\r\n")) + _, _ = reader.ReadString('\n') + // Mitten in der Multiline-Übertragung (RETR-Antwortkopf gelesen, + // Datenzeilen NICHT vollständig konsumiert) hart abbrechen. + _, _ = conn.Write([]byte("RETR 1\r\n")) + _, _ = reader.ReadString('\n') // nur die "+OK ... octets"-Kopfzeile + _ = conn.Close() + } + + // Server braucht kurz, um die abgebrochenen Sessions abzuräumen. + deadline := time.Now().Add(3 * time.Second) + for { + runtime.GC() + current := runtime.NumGoroutine() + if current <= baseline+2 { // kleine Toleranz für Laufzeit-Jitter + return + } + if time.Now().After(deadline) { + t.Fatalf("goroutine-leck nach hartem Verbindungsabbruch: baseline=%d, aktuell=%d", baseline, current) + } + time.Sleep(50 * time.Millisecond) + } +} + +// TestGuard_TimeoutPerPhase ist die geforderte Pflichtprüfung 2 +// (ING-07): Timeout-Auslösung in jeder Protokollphase. +func TestGuard_TimeoutPerPhase(t *testing.T) { + cfg := protoguard.Config{ + PhaseTimeout: map[protoguard.Phase]time.Duration{ + phaseAuthorization: 100 * time.Millisecond, + phaseTransaction: 100 * time.Millisecond, + }, + DefaultTimeout: 5 * time.Second, + } + + t.Run("authorization", func(t *testing.T) { + addr, _, stop := startTestServerWithGuardConfig(t, cfg) + defer stop() + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + reader := bufio.NewReader(conn) + _, _ = reader.ReadString('\n') // Begrüßung, aber nichts weiter senden + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = reader.ReadString('\n') + if err == nil { + t.Fatalf("erwartete Verbindungsende durch Authorization-Timeout") + } + }) + + t.Run("transaction", func(t *testing.T) { + addr, _, stop := startTestServerWithGuardConfig(t, cfg) + defer stop() + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + reader := bufio.NewReader(conn) + _, _ = reader.ReadString('\n') + _, _ = conn.Write([]byte("USER alice\r\n")) + _, _ = reader.ReadString('\n') + _, _ = conn.Write([]byte("PASS geheim123\r\n")) + _, _ = reader.ReadString('\n') // jetzt in Transaction, nichts weiter senden + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = reader.ReadString('\n') + if err == nil { + t.Fatalf("erwartete Verbindungsende durch Transaction-Timeout") + } + }) +} + +// TestGuard_BackoffOnRepeatedAuthFailures ist die geforderte +// Pflichtprüfung 3 (ING-07): Backoff-Verhalten bei wiederholten +// Fehlversuchen statt Dauerschleife. +func TestGuard_BackoffOnRepeatedAuthFailures(t *testing.T) { + cfg := protoguard.Config{ + DefaultTimeout: 5 * time.Second, + MaxAuthFailures: 3, + BackoffBase: 50 * time.Millisecond, + BackoffMax: 500 * time.Millisecond, + } + addr, _, stop := startTestServerWithGuardConfig(t, cfg) + defer stop() + + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + reader := bufio.NewReader(conn) + _, _ = reader.ReadString('\n') + + var attemptDurations []time.Duration + for i := 0; i < 3; i++ { + _, _ = conn.Write([]byte("USER alice\r\n")) + _, _ = reader.ReadString('\n') + + start := time.Now() + _, _ = conn.Write([]byte("PASS falsch\r\n")) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + resp, err := reader.ReadString('\n') + if err != nil { + if i < 2 { + t.Fatalf("fehlversuch %d: unerwarteter Verbindungsabbruch: %v", i+1, err) + } + // dritter Fehlversuch: Trennung nach der Antwort ist erlaubt. + } else if !strings.Contains(resp, "-ERR") { + t.Fatalf("fehlversuch %d: erwartete -ERR, habe: %q", i+1, resp) + } + attemptDurations = append(attemptDurations, time.Since(start)) + } + + // Backoff steigt: der zweite Fehlversuch muss spürbar länger dauern + // als der erste (Verdopplung statt konstanter/keiner Wartezeit). + if attemptDurations[1] <= attemptDurations[0] { + t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations) + } + + // Nach MaxAuthFailures muss die Verbindung getrennt werden (kein + // endloses erneutes USER/PASS erlaubt) statt in Dauerschleife. + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := conn.Write([]byte("USER alice\r\n")); err == nil { + _, err = reader.ReadString('\n') + if err == nil { + t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures) + } + } +} diff --git a/mail/internal/pop3/server.go b/mail/internal/pop3/server.go index 4a2e797..ce30866 100644 --- a/mail/internal/pop3/server.go +++ b/mail/internal/pop3/server.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" ) // Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen @@ -12,12 +14,20 @@ import ( // mail/internal/imap.Server. TLS/STARTTLS ist Sache von ING-06, nicht // dieser Kachel. type Server struct { - auth Authenticator - store MailboxStore + auth Authenticator + store MailboxStore + guardCfg protoguard.Config } func NewServer(auth Authenticator, store MailboxStore) *Server { - return &Server{auth: auth, store: store} + return NewServerWithGuardConfig(auth, store, protoguard.DefaultConfig()) +} + +// NewServerWithGuardConfig erlaubt abweichende Phase-Timeouts und +// Backoff-Parameter (ING-07), z. B. für Tests oder gehärtete +// Betriebsumgebungen. +func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Server { + return &Server{auth: auth, store: store, guardCfg: guardCfg} } // Serve nimmt Verbindungen auf listener an, bis ctx beendet wird. @@ -39,7 +49,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error { } return fmt.Errorf("pop3: verbindung annehmen: %w", err) } - session := newSession(conn, srv.auth, srv.store) + session := newSession(conn, srv.auth, srv.store, srv.guardCfg) go session.Serve(ctx) } } diff --git a/mail/internal/pop3/session.go b/mail/internal/pop3/session.go index 8fc1208..7bbd11d 100644 --- a/mail/internal/pop3/session.go +++ b/mail/internal/pop3/session.go @@ -7,6 +7,16 @@ import ( "io" "net" "strings" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" +) + +// phaseAuthorization/phaseTransaction sind die protoguard-Phasen dieser +// Sitzung (ING-07 Akzeptanzkriterium 2: Timeouts pro Protokollphase +// konfigurierbar). +const ( + phaseAuthorization protoguard.Phase = "authorization" + phaseTransaction protoguard.Phase = "transaction" ) // maxCommandLineBytes begrenzt eine einzelne Kommandozeile (defensive @@ -22,6 +32,7 @@ type Session struct { writer *bufio.Writer auth Authenticator store MailboxStore + guard *protoguard.Guard state State pendingUsername string // nach USER, vor erfolgreichem PASS @@ -29,18 +40,27 @@ type Session struct { deleted map[int]bool } -func newSession(conn net.Conn, auth Authenticator, store MailboxStore) *Session { +func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Session { return &Session{ conn: conn, reader: bufio.NewReaderSize(conn, maxCommandLineBytes), writer: bufio.NewWriter(conn), auth: auth, store: store, + guard: protoguard.New(guardCfg), state: Authorization, deleted: map[int]bool{}, } } +// currentPhase liefert die protoguard-Phase des aktuellen Sitzungszustands. +func (s *Session) currentPhase() protoguard.Phase { + if s.state == Authorization { + return phaseAuthorization + } + return phaseTransaction +} + // State liefert den aktuellen Sitzungszustand (für Tests). func (s *Session) State() State { return s.state } @@ -53,13 +73,23 @@ func (s *Session) Serve(ctx context.Context) { } for { + // Akzeptanzkriterium 2 (ING-07): Idle-Timeout pro Protokollphase, + // vor jedem Lesevorgang neu gesetzt, da ein Zustandswechsel die + // Phase (und damit den geltenden Timeout) ändern kann. + if err := s.guard.ApplyReadDeadline(s.conn, s.currentPhase()); err != nil { + return + } line, err := s.readLine() if err != nil { - // Verbindung endet OHNE QUIT — Akzeptanzkriterium/ - // Pflichtprüfung 3: als Deleted markierte Nachrichten dürfen - // dadurch NICHT gelöscht werden. Da store.Delete nur im - // regulären handleQuit aufgerufen wird, ist das hier bereits - // strukturell garantiert (kein Aufruf, keine Löschung). + // Verbindung endet OHNE QUIT (Timeout, Netzwerkabbruch oder + // harter Verbindungsabbruch) — Akzeptanzkriterium 1: die + // Session-Ressourcen (Verbindung, Reader/Writer) werden über + // das defer conn.Close() oben zuverlässig freigegeben. + // Zusätzlich Pflichtprüfung 3: als Deleted markierte + // Nachrichten dürfen dadurch NICHT gelöscht werden. Da + // store.Delete nur im regulären handleQuit aufgerufen wird, + // ist das hier bereits strukturell garantiert (kein Aufruf, + // keine Löschung). return } if line == "" { diff --git a/mail/internal/protoguard/protoguard.go b/mail/internal/protoguard/protoguard.go new file mode 100644 index 0000000..8242613 --- /dev/null +++ b/mail/internal/protoguard/protoguard.go @@ -0,0 +1,119 @@ +// Package protoguard bündelt die Fehlerbehandlungs- und +// Wiederverbindungslogik, die IMAP- und POP3-Sessions gemeinsam +// brauchen (ING-07): pro Protokollphase konfigurierbare Idle-Timeouts +// und Backoff statt Dauerschleife bei wiederholten Anmeldefehlern. +// Ressourcenaufräumung selbst passiert bereits strukturell durch +// defer conn.Close() in den Sessions — Guard sorgt dafür, dass dieser +// Pfad auch bei hängenden oder böswilligen Gegenstellen zuverlässig +// erreicht wird. +package protoguard + +import ( + "context" + "net" + "time" +) + +// Phase identifiziert eine Protokollphase, für die ein eigener +// Idle-Timeout gilt. +type Phase string + +// Config steuert Timeout- und Backoff-Verhalten einer Verbindung. +type Config struct { + // PhaseTimeout liefert den Idle-Timeout je Phase. Fehlt ein Eintrag, + // gilt DefaultTimeout. + PhaseTimeout map[Phase]time.Duration + // DefaultTimeout gilt, wenn für die aktuelle Phase kein eigener Wert + // gesetzt ist. 0 bedeutet: kein Timeout. + DefaultTimeout time.Duration + // MaxAuthFailures ist die Anzahl fehlgeschlagener Anmeldeversuche, + // nach der eine Verbindung getrennt wird. 0 bedeutet: unbegrenzt + // (kein Trennen, nur Backoff). + MaxAuthFailures int + // BackoffBase ist die Wartezeit vor der Antwort nach dem ersten + // Fehlversuch, verdoppelt sich je weiterem Fehlversuch bis + // BackoffMax. + BackoffBase time.Duration + BackoffMax time.Duration +} + +// DefaultConfig liefert praxistaugliche Werte für Produktionsbetrieb. +func DefaultConfig() Config { + return Config{ + DefaultTimeout: 5 * time.Minute, + MaxAuthFailures: 5, + BackoffBase: 200 * time.Millisecond, + BackoffMax: 5 * time.Second, + } +} + +// Guard kapselt den Fehlerbehandlungszustand EINER Verbindung: aktuell +// angewandte Phase-Timeouts und Zahl der Anmeldefehlversuche. +type Guard struct { + cfg Config + authFailures int +} + +// New erstellt einen Guard für eine einzelne Session. +func New(cfg Config) *Guard { + return &Guard{cfg: cfg} +} + +// ApplyReadDeadline setzt die Lese-Deadline von conn passend zur +// angegebenen Protokollphase (Akzeptanzkriterium 2). +func (g *Guard) ApplyReadDeadline(conn net.Conn, phase Phase) error { + d := g.cfg.DefaultTimeout + if pd, ok := g.cfg.PhaseTimeout[phase]; ok { + d = pd + } + if d <= 0 { + return conn.SetReadDeadline(time.Time{}) + } + return conn.SetReadDeadline(time.Now().Add(d)) +} + +// RecordAuthFailure zählt einen fehlgeschlagenen Anmeldeversuch dieser +// Verbindung und liefert die Backoff-Wartezeit vor der Fehlerantwort +// sowie ob die Verbindung danach getrennt werden muss (Akzeptanzkriterium +// 3: klar definierter Backoff statt Dauerschleife). +func (g *Guard) RecordAuthFailure() (backoff time.Duration, disconnect bool) { + g.authFailures++ + backoff = g.backoffFor(g.authFailures) + disconnect = g.cfg.MaxAuthFailures > 0 && g.authFailures >= g.cfg.MaxAuthFailures + return backoff, disconnect +} + +// ResetAuthFailures setzt den Fehlversuchszähler nach erfolgreicher +// Anmeldung zurück. +func (g *Guard) ResetAuthFailures() { g.authFailures = 0 } + +func (g *Guard) backoffFor(failures int) time.Duration { + if g.cfg.BackoffBase <= 0 { + return 0 + } + d := g.cfg.BackoffBase + for i := 1; i < failures; i++ { + d *= 2 + if g.cfg.BackoffMax > 0 && d >= g.cfg.BackoffMax { + return g.cfg.BackoffMax + } + } + if g.cfg.BackoffMax > 0 && d > g.cfg.BackoffMax { + return g.cfg.BackoffMax + } + return d +} + +// Wait wartet d, bricht aber bei ctx-Abbruch sofort ab, damit ein +// Server-Shutdown nicht auf eine laufende Backoff-Pause warten muss. +func (g *Guard) Wait(ctx context.Context, d time.Duration) { + if d <= 0 { + return + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + } +}