Files
nexarch/mail/internal/imap/session.go
T
sysops 7c892ed10a feat(mail): ING-08 strukturiertes Protokoll-Logging & Diagnose für IMAP/POP3/SMTP
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.
2026-09-01 09:07:35 +02:00

174 lines
5.6 KiB
Go

package imap
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"
)
// 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
// Fehlerbehandlung bei nicht-konformen Gegenstellen statt optimistischem
// Parsing, siehe Ticket-Vorbild Dovecot) — verhindert unbegrenztes
// Pufferwachstum durch eine Gegenstelle, die niemals CRLF sendet.
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
guard *protoguard.Guard
tlsConfig *tls.Config // nil = kein TLS/STARTTLS angeboten (ING-06)
tlsActive bool
log *protolog.SessionLogger // ING-08, nie nil (log.Event() ist nil-sicher)
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, 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, "imap"),
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 }
// Serve führt die Sitzung bis LOGOUT 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 := writeUntagged(s.writer, "OK IMAP4rev1 Service 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 {
// Verbindungsende (Timeout, Netzwerkabbruch oder harter
// Abbruch) — Session-Ressourcen werden über das defer
// conn.Close() oben zuverlässig freigegeben
// (Akzeptanzkriterium 1).
return
}
if line == "" {
continue
}
cmd, parseErr := parseCommandLine(line)
if parseErr != nil {
// Akzeptanzkriterium 3: ungültige Kommandosequenz -> Fehlercode,
// KEIN Verbindungsabbruch.
if err := writeUntagged(s.writer, "BAD Error in IMAP command received by server."); err != nil {
return
}
continue
}
// Akzeptanzkriterium 2 (ING-08): LOGIN-Argumente (Passwort)
// erscheinen über RedactCommandLine nie im Klartext im Log.
s.log.Event(ctx, "command", slog.String("command", protolog.RedactCommandLine(cmd.Name, cmd.Args)))
if !s.dispatch(ctx, cmd) {
return // LOGOUT oder nicht behebbarer Schreibfehler
}
}
}
// readLine liest eine CRLF- (oder LF-)terminierte Zeile ohne
// Zeilenumbruch. Überlange Zeilen (siehe maxCommandLineBytes) werden als
// Fehler behandelt statt unbegrenzt zu puffern.
func (s *Session) readLine() (string, error) {
line, err := s.reader.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) && line != "" {
// Letzte Zeile ohne abschließendes LF — als vollständige Zeile
// behandeln, danach ohnehin Verbindungsende.
return strings.TrimRight(line, "\r"), nil
}
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// dispatch verarbeitet EIN geparstes Kommando. Rückgabewert false
// bedeutet: Sitzung beenden (LOGOUT abgeschlossen oder Schreibfehler).
func (s *Session) dispatch(ctx context.Context, cmd command) bool {
switch cmd.Name {
case "CAPABILITY":
return s.handleCapability(cmd)
case "LOGIN":
return s.handleLogin(ctx, cmd)
case "SELECT":
return s.handleSelect(ctx, cmd)
case "FETCH":
return s.handleFetch(ctx, cmd)
case "UID":
if len(cmd.Args) < 1 || strings.ToUpper(cmd.Args[0]) != "FETCH" {
return s.writeErr(cmd.Tag, "BAD", "Unsupported UID subcommand")
}
return s.handleUIDFetch(ctx, cmd)
case "LOGOUT":
return s.handleLogout(cmd)
case "STARTTLS":
return s.handleStartTLS(ctx, cmd)
default:
return s.writeErr(cmd.Tag, "BAD", "Unknown command")
}
}
func (s *Session) writeErr(tag, status, text string) bool {
return writeTagged(s.writer, tag, status, text) == nil
}