Neues Paket mail/internal/protolog (log/slog): SessionLogger loggt strukturierte Ereignisse einer Verbindung mit fester correlation_id und protocol über die gesamte Verbindungsdauer (Akzeptanzkriterium 1) — ein Logger mit logger==nil ist sicher benutzbar und loggt nichts (Rückwärtskompatibilität zu ING-01..ING-07, Logging ist opt-in wie TLS und Guard-Konfiguration). RedactCommandLine ersetzt bei sensiblen Kommandos (PASS, LOGIN, AUTH) alle Argumente vollständig durch [REDACTED] statt einzeln zu parsen (Akzeptanzkriterium 2). Reconstruct liest zeilenweise JSON-Logs und liefert ausschließlich die Einträge einer Korrelations-ID in Reihenfolge — das geforderte Diagnosewerkzeug (Akzeptanzkriterium 3). Alle drei Sessions loggen jetzt session_start/command (je empfangener Zeile, redigiert)/session_end. Nachrichteninhalte werden strukturell nie geloggt: SMTP-DATA-Body-Zeilen laufen durch eine eigene Leseschleife, die nicht durch den Kommando-Logpfad der Hauptschleife kommt: nur das Kommando DATA selbst erscheint im Log. Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt, jeweils in IMAP, POP3 und SMTP einzeln: Redaktion gegen den echten laufenden Server bestätigt (Klartextpasswort bzw. absichtlich eingebettetes Geheimnis im SMTP-Body erscheint nie im Log), zwei gemischte reale Sessions über dieselbe Korrelations-ID lückenlos rekonstruiert, Lasttest mit 100 Sessions mit/ohne Logging ohne relevante Durchsatzeinbuße. go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~29 Pakete) regressionsfrei getestet.
175 lines
5.2 KiB
Go
175 lines
5.2 KiB
Go
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"
|
|
)
|
|
|
|
// 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)
|
|
|
|
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) *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"),
|
|
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 }
|
|
|
|
// 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
|
|
}
|
|
}
|