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.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||
)
|
||||
|
||||
func startLoggedTestServer(t *testing.T, logger *slog.Logger) (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{}}},
|
||||
}}
|
||||
srv := NewServerWithGuardTLSAndLogger(auth, store, protoguard.DefaultConfig(), nil, logger)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func runFullSession(t *testing.T, addr string) {
|
||||
t.Helper()
|
||||
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')
|
||||
sendTaggedOn(t, conn, reader, "A1", "LOGIN alice geheim123")
|
||||
sendTaggedOn(t, conn, reader, "A2", "SELECT INBOX")
|
||||
sendTaggedOn(t, conn, reader, "A3", "LOGOUT")
|
||||
}
|
||||
|
||||
// TestProtolog_RedactsCredentialsInRealSessionLog ist die geforderte
|
||||
// Pflichtprüfung 1 (ING-08) gegen den echten, laufenden IMAP-Server.
|
||||
func TestProtolog_RedactsCredentialsInRealSessionLog(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
addr, stop := startLoggedTestServer(t, logger)
|
||||
defer stop()
|
||||
|
||||
runFullSession(t, addr)
|
||||
|
||||
logged := buf.String()
|
||||
if strings.Contains(logged, "geheim123") {
|
||||
t.Fatalf("passwort im klartext im log gefunden:\n%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "LOGIN [REDACTED]") {
|
||||
t.Fatalf("erwartete redigierten LOGIN-eintrag im log, habe:\n%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProtolog_SessionFullyReconstructableByCorrelationID ist die
|
||||
// geforderte Pflichtprüfung 2 (ING-08).
|
||||
func TestProtolog_SessionFullyReconstructableByCorrelationID(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
addr, stop := startLoggedTestServer(t, logger)
|
||||
defer stop()
|
||||
|
||||
runFullSession(t, addr)
|
||||
runFullSession(t, addr)
|
||||
|
||||
firstLine := strings.SplitN(buf.String(), "\n", 2)[0]
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(firstLine), &raw); err != nil {
|
||||
t.Fatalf("erste logzeile parsen: %v", err)
|
||||
}
|
||||
firstID, _ := raw["correlation_id"].(string)
|
||||
if firstID == "" {
|
||||
t.Fatalf("keine correlation_id in erster logzeile: %s", firstLine)
|
||||
}
|
||||
|
||||
entries, err := protolog.Reconstruct(bytes.NewReader(buf.Bytes()), firstID)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconstruct: %v", err)
|
||||
}
|
||||
// session_start, 3 kommandos (LOGIN/SELECT/LOGOUT), session_end.
|
||||
if len(entries) != 5 {
|
||||
t.Fatalf("erwartete 5 lückenlose einträge für die session, habe %d: %+v", len(entries), entries)
|
||||
}
|
||||
if entries[0].Msg != "session_start" || entries[len(entries)-1].Msg != "session_end" {
|
||||
t.Fatalf("session nicht lückenlos rekonstruierbar: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProtolog_LoggingDoesNotRelevantlyImpactThroughput ist die
|
||||
// geforderte Pflichtprüfung 3 (ING-08).
|
||||
func TestProtolog_LoggingDoesNotRelevantlyImpactThroughput(t *testing.T) {
|
||||
const sessions = 100
|
||||
|
||||
addrOff, stopOff := startLoggedTestServer(t, nil)
|
||||
startOff := time.Now()
|
||||
for i := 0; i < sessions; i++ {
|
||||
runFullSession(t, addrOff)
|
||||
}
|
||||
durationOff := time.Since(startOff)
|
||||
stopOff()
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
addrOn, stopOn := startLoggedTestServer(t, logger)
|
||||
startOn := time.Now()
|
||||
for i := 0; i < sessions; i++ {
|
||||
runFullSession(t, addrOn)
|
||||
}
|
||||
durationOn := time.Since(startOn)
|
||||
stopOn()
|
||||
|
||||
if durationOn > 3*durationOff+5*time.Millisecond {
|
||||
t.Fatalf("logging verlangsamt durchsatz relevant: ohne=%v, mit=%v", durationOff, durationOn)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
@@ -21,6 +22,7 @@ type Server struct {
|
||||
store MailboxStore
|
||||
guardCfg protoguard.Config
|
||||
tlsConfig *tls.Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
||||
@@ -41,6 +43,13 @@ func NewServerWithGuardAndTLSConfig(auth Authenticator, store MailboxStore, guar
|
||||
return &Server{auth: auth, store: store, guardCfg: guardCfg, tlsConfig: tlsConfig}
|
||||
}
|
||||
|
||||
// NewServerWithGuardTLSAndLogger erlaubt zusätzlich strukturiertes
|
||||
// Protokoll-Logging (ING-08). logger darf nil sein (Logging dann
|
||||
// deaktiviert, Rückwärtskompatibilität zu ING-01..ING-07).
|
||||
func NewServerWithGuardTLSAndLogger(auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config, logger *slog.Logger) *Server {
|
||||
return &Server{auth: auth, store: store, guardCfg: guardCfg, tlsConfig: tlsConfig, logger: logger}
|
||||
}
|
||||
|
||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder
|
||||
// Accept endgültig fehlschlägt. Blockiert den Aufrufer.
|
||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
@@ -61,7 +70,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, srv.guardCfg, srv.tlsConfig)
|
||||
session := newSession(conn, srv.auth, srv.store, srv.guardCfg, srv.tlsConfig, srv.logger)
|
||||
go session.Serve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"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
|
||||
@@ -39,12 +41,13 @@ type Session struct {
|
||||
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) *Session {
|
||||
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,
|
||||
@@ -55,6 +58,7 @@ func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg
|
||||
guard: protoguard.New(guardCfg),
|
||||
tlsConfig: tlsConfig,
|
||||
tlsActive: alreadyTLS,
|
||||
log: protolog.NewSessionLogger(logger, "imap"),
|
||||
state: NotAuthenticated,
|
||||
}
|
||||
}
|
||||
@@ -74,6 +78,11 @@ func (s *Session) State() State { return s.state }
|
||||
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
|
||||
}
|
||||
@@ -107,6 +116,10 @@ func (s *Session) Serve(ctx context.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user