package pop3 import ( "bufio" "context" "crypto/tls" "errors" "io" "log/slog" "net" "strings" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog" "gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit" ) // 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 // Fehlerbehandlung bei nicht-konformen Gegenstellen, gleiche Konvention // wie mail/internal/imap). const maxCommandLineBytes = 8192 // Session ist eine einzelne POP3-Verbindung mit eigener Zustandsmaschine // (Akzeptanzkriterium 1). type Session struct { conn net.Conn reader *bufio.Reader writer *bufio.Writer auth Authenticator store MailboxStore guard *protoguard.Guard // tlsConfig ist nil, wenn dieser Server ohne TLS/STLS betrieben wird // (Akzeptanzkriterium 1 greift dann nicht — Rückwärtskompatibilität // zu ING-01/ING-02). tlsActive ist von Anfang an true, wenn die // Verbindung bereits über einen impliziten TLS-Listener hereinkam. tlsConfig *tls.Config tlsActive bool log *protolog.SessionLogger // ING-08, nie nil (aber log.Event() ist nil-sicher) // tenantID/loginLimiter: Rate-Limiting für PASS-Versuche (ING-09). // loginLimiter nil bedeutet: kein Rate-Limiting (Rückwärtskompatibilität // zu ING-01..ING-08). tenantID string loginLimiter *ratelimit.Limiter state State pendingUsername string // nach USER, vor erfolgreichem PASS username string // nach erfolgreichem PASS deleted map[int]bool } func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, loginLimiter *ratelimit.Limiter) *Session { _, alreadyTLS := conn.(*tls.Conn) return &Session{ conn: conn, reader: bufio.NewReaderSize(conn, maxCommandLineBytes), writer: bufio.NewWriter(conn), auth: auth, store: store, guard: protoguard.New(guardCfg), tlsConfig: tlsConfig, tlsActive: alreadyTLS, log: protolog.NewSessionLogger(logger, "pop3"), tenantID: tenantID, loginLimiter: loginLimiter, state: Authorization, deleted: map[int]bool{}, } } // sourceAddr liefert die Quell-IP dieser Verbindung ohne Portanteil, // für das Rate-Limiting (ING-09) und als Schlüssel gegenüber dem // Limiter stabil pro Client. func (s *Session) sourceAddr() string { host, _, err := net.SplitHostPort(s.conn.RemoteAddr().String()) if err != nil { return s.conn.RemoteAddr().String() } return host } // 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 } // Serve führt die Sitzung bis QUIT oder Verbindungsende aus. func (s *Session) Serve(ctx context.Context) { defer func() { _ = s.conn.Close() }() // Akzeptanzkriterium 1 (ING-08): strukturierte Logs mit // Korrelations-ID über die gesamte Verbindungsdauer. s.log.Event(ctx, "session_start", slog.String("remote_addr", s.conn.RemoteAddr().String())) defer s.log.Event(ctx, "session_end") if err := writeOK(s.writer, "POP3 server ready"); err != nil { return } 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 (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 == "" { continue } cmd := parseCommandLine(line) if cmd.Name == "" { if err := writeErr(s.writer, "unrecognized command"); err != nil { return } continue } // Akzeptanzkriterium 2 (ING-08): Zugangsdaten (PASS-Argument) // erscheinen über RedactCommandLine nie im Klartext im Log. // Nachrichteninhalte werden hier grundsätzlich nicht geloggt — // RETR/LIST-Antworten sind kein Bestandteil dieses Ereignisses. s.log.Event(ctx, "command", slog.String("command", protolog.RedactCommandLine(cmd.Name, cmd.Args))) if !s.dispatch(ctx, cmd) { return } } } func (s *Session) readLine() (string, error) { line, err := s.reader.ReadString('\n') if err != nil { if errors.Is(err, io.EOF) && line != "" { return strings.TrimRight(line, "\r"), nil } return "", err } return strings.TrimRight(line, "\r\n"), nil } // dispatch verarbeitet EIN geparstes Kommando. false bedeutet: Sitzung // beenden (QUIT abgeschlossen oder Schreibfehler). func (s *Session) dispatch(ctx context.Context, cmd command) bool { switch cmd.Name { case "USER": return s.handleUser(cmd) case "PASS": return s.handlePass(ctx, cmd) case "STAT": return s.handleStat(ctx) case "LIST": return s.handleList(ctx, cmd) case "RETR": return s.handleRetr(ctx, cmd) case "DELE": return s.handleDele(cmd) case "QUIT": return s.handleQuit(ctx) case "STLS": return s.handleStls(ctx) default: return writeErr(s.writer, "unknown command") == nil } }