1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 05:32:31 +00:00
Files
go-inbucket/pkg/server/pop3/handler.go
T
Antoine Deschênes 9876bb2bd9 fix(pop3): track STLS state per session instead of per server (#626)
The TLS connection state lived on the shared POP3 Server, and Session
reached it through its embedded *Server.  Once any client completed
STLS, every later session saw a non-nil state: CAPA stopped advertising
STLS and the STLS command returned "-ERR A TLS session already agreed
upon." until the server restarted.  Sessions also wrote the shared field
concurrently without synchronization.

Move tlsState to Session.  ForceTLS sessions set it when the session
starts, and STLS sets it on the negotiating session only.

TestSTLSPerSession runs CAPA, STLS and a TLS handshake on two sessions of
one server; it fails against the previous code on the second session.

Signed-off-by: Antoine Deschênes <antoine@antoinedeschenes.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:32 -07:00

585 lines
16 KiB
Go

package pop3
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// State tracks the current mode of our POP3 state machine
type State int
const (
// AUTHORIZATION state: the client must now identify and authenticate
AUTHORIZATION State = iota
// TRANSACTION state: mailbox open, client may now issue commands
TRANSACTION
// QUIT state: client requests us to end session
QUIT
)
func (s State) String() string {
switch s {
case AUTHORIZATION:
return "AUTHORIZATION"
case TRANSACTION:
return "TRANSACTION"
case QUIT:
return "QUIT"
}
return "Unknown"
}
var commands = map[string]bool{
"QUIT": true,
"STAT": true,
"LIST": true,
"RETR": true,
"DELE": true,
"NOOP": true,
"RSET": true,
"TOP": true,
"UIDL": true,
"USER": true,
"PASS": true,
"APOP": true,
"CAPA": true,
"STLS": true,
}
// Session defines an active POP3 session
type Session struct {
*Server // Reference to the server we belong to.
id int // Session ID number.
conn net.Conn // Our network connection.
remoteHost string // IP address of client.
sendError error // Used to bail out of read loop on send error.
state State // Current session state.
reader *bufio.Reader // Buffered reader for our net conn.
user string // Mailbox name.
messages []storage.Message // Slice of messages in mailbox.
retain []bool // Messages to retain upon UPDATE (true=retain).
msgCount int // Number of undeleted messages.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
tlsState *tls.ConnectionState // TLS connection state, nil until TLS is set up.
}
// NewSession creates a new POP3 session
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{
Server: server,
id: id,
conn: conn,
state: AUTHORIZATION,
reader: reader,
remoteHost: host,
logger: logger,
debug: server.config.Debug,
}
}
func (s *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(ctx context.Context, id int, conn net.Conn) {
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Debug().Msgf("ForceTLS: %t", s.config.ForceTLS)
connToClose := conn
var tlsState *tls.ConnectionState
if s.config.ForceTLS {
logger.Debug().Msg("Setting up TLS for ForceTLS")
tlsConn := tls.Server(conn, s.tlsConfig)
tlsState = new(tls.ConnectionState)
*tlsState = tlsConn.ConnectionState()
conn = tlsConn
}
logger.Info().Msg("Starting POP3 session")
defer func() {
logger.Debug().Msg("closing at end of session")
// Closing the tlsConn hangs.
if err := connToClose.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
}
logger.Debug().Msg("End of session")
s.wg.Done()
}()
ssn := NewSession(s, id, conn, logger)
ssn.tlsState = tlsState
ssn.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
time.Now().Unix(), s.config.Domain))
// This is our command reading loop
for ssn.state != QUIT && ssn.sendError == nil {
line, err := ssn.readLine()
ssn.logger.Debug().Msgf("read %s", line)
if err == nil {
cmd, arg := ssn.parseCmd(line)
// Commands we handle in any state
if cmd == "CAPA" {
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
if s.tlsConfig != nil && ssn.tlsState == nil && !s.config.ForceTLS {
ssn.send("STLS")
}
ssn.send(".")
continue
}
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(ctx, cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
// readLine() returned an error
if err == io.EOF {
switch ssn.state {
case AUTHORIZATION:
// EOF is common here
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
default:
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
}
break
}
// not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
ssn.send("-ERR Idle timeout, bye bye")
break
}
}
ssn.send("-ERR Connection error, sorry")
break
}
}
if ssn.sendError != nil {
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
}
ssn.logger.Info().Msgf("Closing connection")
}
// AUTHORIZATION state
func (s *Session) authorizationHandler(ctx context.Context, cmd string, args []string) {
switch cmd {
case "QUIT":
s.send("+OK Goodnight and good luck")
s.logger.Debug().Msg("Quitting.")
s.enterState(QUIT)
case "STLS":
if !s.config.TLSEnabled || s.config.ForceTLS {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
s.send("-ERR TLS unavailable on the server")
return
}
if s.tlsState != nil {
// TLS state previously valid.
s.logger.Debug().Msg("-ERR A TLS session already agreed upon.")
s.send("-ERR A TLS session already agreed upon.")
return
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
tlsCtx, cancel := context.WithTimeout(ctx, s.config.Timeout)
defer cancel()
s.send("+OK Begin TLS Negotiation")
tlsConn := tls.Server(s.conn, s.tlsConfig)
if err := tlsConn.HandshakeContext(tlsCtx); err != nil {
// RFC 2595 §4: the negotiation must complete before any further
// commands are sent, so a failure is fatal to the session.
s.logger.Error().Msgf("TLS handshake failed: %v", err)
s.enterState(QUIT)
return
}
s.conn = tlsConn
s.reader = bufio.NewReader(tlsConn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.logger.Debug().Msgf("TLS set %v", *s.tlsState)
case "USER":
if len(args) > 0 {
s.user = args[0]
s.send(fmt.Sprintf("+OK Hello %v, welcome to Inbucket", s.user))
} else {
s.send("-ERR Missing username argument")
}
case "PASS":
if s.user == "" {
s.ooSeq(cmd)
} else {
s.loadMailbox()
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
s.enterState(TRANSACTION)
}
case "APOP":
if len(args) != 2 {
s.logger.Warn().Msgf("Expected two arguments for APOP")
s.send("-ERR APOP requires two arguments")
return
}
s.user = args[0]
s.loadMailbox()
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
s.enterState(TRANSACTION)
default:
s.ooSeq(cmd)
}
}
// TRANSACTION state
func (s *Session) transactionHandler(cmd string, args []string) {
switch cmd {
case "STAT":
if len(args) != 0 {
s.logger.Warn().Msgf("STAT got an unexpected argument")
s.send("-ERR STAT command must have no arguments")
return
}
var count int
var size int64
for i, msg := range s.messages {
if s.retain[i] {
count++
size += msg.Size()
}
}
s.send(fmt.Sprintf("+OK %v %v", count, size))
case "LIST":
s.listMessages("LIST", args, func(m storage.Message) string {
return strconv.FormatInt(m.Size(), 10)
})
case "UIDL":
s.listMessages("UIDL", args, func(m storage.Message) string {
return m.ID()
})
case "DELE":
if len(args) != 1 {
s.logger.Warn().Msgf("DELE command had invalid number of arguments")
s.send("-ERR DELE command requires a single argument")
return
}
msgNum, ok := s.validateMsgNum("DELE", "DELE", args[0])
if !ok {
return
}
if s.retain[msgNum-1] {
s.retain[msgNum-1] = false
s.msgCount--
s.send(fmt.Sprintf("+OK Deleted message %v", msgNum))
} else {
s.logger.Warn().Msgf("Client tried to DELE an already deleted message")
s.send(fmt.Sprintf("-ERR Message %v has already been deleted", msgNum))
}
case "RETR":
if len(args) != 1 {
s.logger.Warn().Msgf("RETR command had invalid number of arguments")
s.send("-ERR RETR command requires a single argument")
return
}
msgNum, ok := s.validateMsgNum("RETR", "RETR", args[0])
if !ok {
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to RETR a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(fmt.Sprintf("+OK %v bytes follows", s.messages[msgNum-1].Size()))
s.sendMessageLines(s.messages[msgNum-1], -1)
case "TOP":
if len(args) != 2 {
s.logger.Warn().Msgf("TOP command had invalid number of arguments")
s.send("-ERR TOP command requires two arguments")
return
}
msgNum, ok := s.validateMsgNum("TOP", "TOP first", args[0])
if !ok {
return
}
var lines int64
lines, err := strconv.ParseInt(args[1], 10, 32)
if err != nil {
s.logger.Warn().Msgf("TOP command second argument was not an integer")
s.send("-ERR TOP command requires an integer argument")
return
}
if lines < 0 {
s.logger.Warn().Msgf("TOP command second argument was negative")
s.send("-ERR TOP second argument must be non-negative")
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to TOP a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send("+OK Top of message follows")
s.sendMessageLines(s.messages[msgNum-1], int(lines))
case "QUIT":
s.send("+OK We will process your deletes")
s.processDeletes()
s.enterState(QUIT)
case "NOOP":
s.send("+OK I have successfully done nothing")
case "RSET":
// Reset session, don't actually delete anything I told you to
s.logger.Debug().Msgf("Resetting session state on RSET request")
s.reset()
s.send("+OK Session reset")
default:
s.ooSeq(cmd)
}
}
// listMessages implements the LIST and UIDL commands, which differ only in
// the value reported for each message.
func (s *Session) listMessages(cmd string, args []string, value func(m storage.Message) string) {
if len(args) > 1 {
s.logger.Warn().Msgf("%v command had more than 1 argument", cmd)
s.send(fmt.Sprintf("-ERR %v command must have zero or one argument", cmd))
return
}
if len(args) == 1 {
msgNum, ok := s.validateMsgNum(cmd, cmd, args[0])
if !ok {
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to %v a message it had deleted", cmd)
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(fmt.Sprintf("+OK %v %v", msgNum, value(s.messages[msgNum-1])))
return
}
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
for i, msg := range s.messages {
if s.retain[i] {
s.send(fmt.Sprintf("%v %v", i+1, value(msg)))
}
}
s.send(".")
}
// validateMsgNum parses arg as a message number and range-checks it against
// the messages in the mailbox, sending a -ERR response and returning ok=false
// when invalid. cmd labels the command in responses; argLabel labels the
// argument (e.g. "RETR", "TOP first").
func (s *Session) validateMsgNum(cmd, argLabel, arg string) (msgNum int, ok bool) {
n, err := strconv.ParseInt(arg, 10, 32)
if err != nil {
s.logger.Warn().Msgf("%v command argument was not an integer", cmd)
s.send(fmt.Sprintf("-ERR %v command requires an integer argument", cmd))
return 0, false
}
if n < 1 {
s.logger.Warn().Msgf("%v argument was less than 1", argLabel)
s.send(fmt.Sprintf("-ERR %v argument must be greater than 0", argLabel))
return 0, false
}
if int(n) > len(s.messages) {
s.logger.Warn().Msgf("%v argument was greater than number of messages", argLabel)
s.send(fmt.Sprintf("-ERR %v argument must not exceed the number of messages", argLabel))
return 0, false
}
return int(n), true
}
// sendMessageLines sends the raw contents of msg to the client, dot-stuffing
// any lines that begin with a period. If maxBodyLines is negative the entire
// message is sent; otherwise the headers (and separating blank line) plus at
// most maxBodyLines lines of the body are sent.
func (s *Session) sendMessageLines(msg storage.Message, maxBodyLines int) {
reader, err := msg.Source()
if err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send("-ERR Failed to RETR that message, internal error")
return
}
defer func() {
if err := reader.Close(); err != nil {
s.logger.Error().Msgf("Failed to close message: %v", err)
}
}()
scanner := bufio.NewScanner(reader)
inBody := false
for scanner.Scan() {
line := scanner.Text()
// Lines starting with . must be prefixed with another .
if strings.HasPrefix(line, ".") {
line = "." + line
}
if inBody {
if maxBodyLines >= 0 && maxBodyLines < 1 {
break
}
maxBodyLines--
} else if line == "" {
// We've hit the end of the header
inBody = true
}
s.send(line)
}
if err = scanner.Err(); err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send(".")
s.send("-ERR Failed to RETR that message, internal error")
return
}
s.send(".")
}
// Load the users mailbox
func (s *Session) loadMailbox() {
s.logger = s.logger.With().Str("mailbox", s.user).Logger()
m, err := s.store.GetMessages(s.user)
if err != nil {
s.logger.Error().Msgf("Failed to load messages for %v: %v", s.user, err)
}
s.messages = m
s.retainAll()
}
// Reset retain flag to true for all messages
func (s *Session) retainAll() {
s.retain = make([]bool, len(s.messages))
for i := range s.retain {
s.retain[i] = true
}
s.msgCount = len(s.messages)
}
// This would be considered the "UPDATE" state in the RFC, but it does not fit
// with our state-machine design here, since no commands are accepted - it just
// indicates that the session was closed cleanly and that deletes should be
// processed.
func (s *Session) processDeletes() {
s.logger.Info().Msgf("Processing deletes")
for i, msg := range s.messages {
if !s.retain[i] {
s.logger.Debug().Str("id", msg.ID()).Msg("Deleting message")
if err := s.store.RemoveMessage(s.user, msg.ID()); err != nil {
s.logger.Warn().Str("id", msg.ID()).Err(err).Msg("Error deleting message")
}
}
}
}
func (s *Session) enterState(state State) {
s.state = state
s.logger.Debug().Msgf("Entering state %v", state)
}
// nextDeadline calculates the next read or write deadline based on configured timeout.
func (s *Session) nextDeadline() time.Time {
return time.Now().Add(s.config.Timeout)
}
// Send requested message, store errors in Session.sendError
func (s *Session) send(msg string) {
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
s.sendError = err
return
}
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
s.sendError = err
s.logger.Warn().Msgf("Failed to send: %q", msg)
return
}
if s.debug {
fmt.Printf("%04d > %v\n", s.id, msg)
}
}
// Reads a line of input
func (s *Session) readLine() (line string, err error) {
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return "", err
}
line, err = s.reader.ReadString('\n')
if err != nil {
return "", err
}
if s.debug {
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
}
return line, nil
}
func (s *Session) parseCmd(line string) (cmd string, args []string) {
line = strings.TrimRight(line, "\r\n")
if line == "" {
return "", nil
}
words := strings.Split(line, " ")
return strings.ToUpper(words[0]), words[1:]
}
func (s *Session) reset() {
s.retainAll()
}
func (s *Session) ooSeq(cmd string) {
s.send(fmt.Sprintf("-ERR Command %v is out of sequence", cmd))
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
}