internal/honeypot/ftp/ftp.go

package ftp

import (
	"bufio"
	"context"
	"crypto/tls"
	"fmt"
	"log/slog"
	"net"
	"strings"
	"sync"
	"time"

	"honeypot/internal/database"
	"honeypot/internal/honeypot"
	"honeypot/internal/logger"
	tlscert "honeypot/internal/tls"
	"honeypot/internal/types"
	"honeypot/internal/utils"
)

const (
	HoneypotType             = types.HoneypotTypeFTP
	HoneypotLabel            = "FTP"
	DefaultConnectionTimeout = 30 * time.Second
	ServerBanner             = "220 FTP server ready\r\n"
)

// Config holds the configuration for the FTP honeypot.
type Config struct {
	ListenAddr  string
	Ports       []uint16
	FTPSPorts   []uint16
	Certificate *tls.Certificate
	CertConfig  tlscert.CertConfig
}

// ftpHoneypot implements the honeypot.Honeypot interface.
type ftpHoneypot struct {
	config    Config
	logger    *slog.Logger
	tlsConfig *tls.Config
}

type ftpSession struct {
	remoteHost string
	remotePort uint16
	dstPort    uint16
	user       string
	tlsEnabled bool
	tlsType    string
}

// New creates a new FTP honeypot instance.
func New(cfg Config) honeypot.Honeypot {
	h := &ftpHoneypot{config: cfg}
	if cfg.Certificate != nil {
		h.tlsConfig = &tls.Config{
			Certificates: []tls.Certificate{*cfg.Certificate},
			MinVersion:   tls.VersionTLS12,
		}
	}
	return h
}

// Name returns the name of this honeypot.
func (h *ftpHoneypot) Name() types.HoneypotType { return HoneypotType }

// Label returns the label of this honeypot.
func (h *ftpHoneypot) Label() string {
	return HoneypotLabel
}

// Start starts the FTP honeypot server.
func (h *ftpHoneypot) Start(ctx context.Context, l *slog.Logger) error {
	h.logger = l

	if h.tlsConfig == nil && len(h.config.FTPSPorts) > 0 {
		if cert, err := tlscert.GenerateSelfSignedCert(h.config.ListenAddr, h.config.CertConfig, h.logger); err == nil {
			h.tlsConfig = &tls.Config{
				Certificates: []tls.Certificate{*cert},
				MinVersion:   tls.VersionTLS12,
			}
		}
	}

	var wg sync.WaitGroup
	startServer := func(port uint16, useTLS bool) {
		if port == 0 {
			return
		}
		wg.Add(1)
		go func() {
			defer wg.Done()
			h.listenAndServe(ctx, port, useTLS)
		}()
	}

	for _, port := range h.config.Ports {
		startServer(port, false)
	}
	for _, port := range h.config.FTPSPorts {
		startServer(port, true)
	}

	wg.Wait()
	return nil
}

func (h *ftpHoneypot) listenAndServe(ctx context.Context, port uint16, useTLS bool) {
	addr := utils.BuildAddress(h.config.ListenAddr, port)
	var ln net.Listener
	var err error

	if useTLS {
		if h.tlsConfig == nil {
			return
		}
		ln, err = tls.Listen("tcp", addr, h.tlsConfig)
	} else {
		ln, err = net.Listen("tcp", addr)
	}

	if err != nil {
		logger.LogError(h.logger, HoneypotType, "listen_failed", err, []any{"addr", addr})
		return
	}
	defer ln.Close()

	go func() {
		<-ctx.Done()
		ln.Close()
	}()

	logger.LogInfo(h.logger, HoneypotType, "honeypot listening", []any{
		"port", port,
		"tls", useTLS,
	})

	for {
		conn, err := ln.Accept()
		if err != nil {
			if ctx.Err() != nil {
				return
			}
			continue
		}
		go h.handleSession(conn, useTLS)
	}
}

func (h *ftpHoneypot) handleSession(conn net.Conn, implicitTLS bool) {
	defer conn.Close()
	conn.SetDeadline(time.Now().Add(DefaultConnectionTimeout))

	rh, rp := utils.SplitAddr(conn.RemoteAddr().String(), h.logger)
	_, dp := utils.SplitAddr(conn.LocalAddr().String(), h.logger)

	session := &ftpSession{
		remoteHost: rh,
		remotePort: rp,
		dstPort:    dp,
		tlsEnabled: implicitTLS,
	}
	if implicitTLS {
		session.tlsType = "ftps"
	}

	var reader *bufio.Reader
	var writer *bufio.Writer

	if implicitTLS {
		reader = bufio.NewReader(conn)
		writer = bufio.NewWriter(conn)
	} else {
		// For non-implicit TLS, we send the banner first before anything else
		// because FTP is a server-speaks-first protocol.
		writer = bufio.NewWriter(conn)
		h.writeLine(writer, ServerBanner)
		reader = bufio.NewReader(conn)
	}

	// If implicit TLS, we need to send the banner AFTER the handshake (which is already done by tls.Listen)
	if implicitTLS {
		h.writeLine(writer, ServerBanner)
	}

	for {
		// Detect TLS handshake on non-TLS port
		if !session.tlsEnabled {
			peek, _ := reader.Peek(1)
			if len(peek) > 0 && peek[0] == 0x16 {
				h.logTLSHandshake(session)
				return
			}
		}

		line, err := reader.ReadString('\n')
		if err != nil {
			break
		}

		line = strings.TrimRight(line, "\r\n")
		parts := strings.Fields(line)
		if len(parts) == 0 {
			continue
		}

		cmd := strings.ToUpper(parts[0])
		arg := ""
		if len(parts) > 1 {
			arg = parts[1]
		}

		switch cmd {
		case "AUTH":
			if strings.ToUpper(arg) == "TLS" || strings.ToUpper(arg) == "SSL" {
				if session.tlsEnabled || h.tlsConfig == nil {
					h.writeLine(writer, "502 Command not implemented\r\n")
					continue
				}
				h.writeLine(writer, "234 Proceed with negotiation.\r\n")
				tlsConn := tls.Server(conn, h.tlsConfig)
				if err := tlsConn.Handshake(); err != nil {
					return
				}
				conn = tlsConn
				reader = bufio.NewReader(conn)
				writer = bufio.NewWriter(conn)
				session.tlsEnabled = true
				session.tlsType = "auth-tls"
			} else {
				h.writeLine(writer, "504 Security mechanism not supported\r\n")
			}
		case "PBSZ":
			h.writeLine(writer, "200 PBSZ=0\r\n")
		case "PROT":
			if strings.ToUpper(arg) == "P" {
				h.writeLine(writer, "200 Private\r\n")
			} else {
				h.writeLine(writer, "200 Clear\r\n")
			}
		case "USER":
			session.user = arg
			h.writeLine(writer, fmt.Sprintf("331 Password required for %s\r\n", arg))
		case "PASS":
			h.logAuthAttempt(session, session.user, arg)
			h.writeLine(writer, "530 Login incorrect\r\n")
		case "SYST":
			h.writeLine(writer, "215 UNIX Type: L8\r\n")
		case "PWD":
			h.writeLine(writer, "257 \"/\" is current directory\r\n")
		case "TYPE":
			h.writeLine(writer, "200 Type set to I\r\n")
		case "PASV":
			h.writeLine(writer, "227 Entering Passive Mode (127,0,0,1,0,21)\r\n")
		case "LIST":
			h.writeLine(writer, "150 Opening ASCII mode data connection for file list\r\n")
			h.writeLine(writer, "226 Transfer complete\r\n")
		case "QUIT":
			h.writeLine(writer, "221 Goodbye\r\n")
			return
		case "FEAT":
			feat := "211-Features:\r\n MDTM\r\n REST STREAM\r\n SIZE\r\n"
			if h.tlsConfig != nil && !session.tlsEnabled {
				feat += " AUTH TLS\r\n AUTH SSL\r\n"
			}
			feat += "211 End\r\n"
			h.writeLine(writer, feat)
		case "OPTS":
			h.writeLine(writer, "200 OPTS command successful\r\n")
		default:
			h.writeLine(writer, "500 Unknown command\r\n")
		}
	}
}

func (h *ftpHoneypot) writeLine(w *bufio.Writer, s string) {
	w.WriteString(s)
	w.Flush()
}

func (h *ftpHoneypot) logAuthAttempt(s *ftpSession, user, pass string) {
	fields := map[string]interface{}{
		"username": user,
		"password": pass,
	}
	if s.tlsEnabled {
		fields["tls"] = s.tlsType
	}
	logger.LogEvent(h.logger, types.LogEvent{
		Type:       HoneypotType,
		Event:      types.EventAuthAttempt,
		RemoteAddr: s.remoteHost,
		RemotePort: s.remotePort,
		DstPort:    s.dstPort,
		Fields:     fields,
	})
}

func (h *ftpHoneypot) logTLSHandshake(s *ftpSession) {
	logger.LogEvent(h.logger, types.LogEvent{
		Type:       HoneypotType,
		Event:      types.EventTLSHandshake,
		RemoteAddr: s.remoteHost,
		RemotePort: s.remotePort,
		DstPort:    s.dstPort,
		Fields:     map[string]interface{}{"message": "TLS handshake attempt on non-TLS port"},
	})
}

func (h *ftpHoneypot) GetScores(db *database.Database, interval string) honeypot.ScoreMap {
	// auth_attempt scores are handled in the honeypot.authAttemptScores function
	return honeypot.ScoreMap{}
}

func (h *ftpHoneypot) Ports() []uint16 {
	ports := append([]uint16{}, h.config.Ports...)
	ports = append(ports, h.config.FTPSPorts...)
	return ports
}