package ftp import ( "bufio" "fmt" "io" "log" "net" "os" "path/filepath" "strconv" "strings" "sync" "time" "ftp-server/config" ) // Server represents an FTP server type Server struct { config *config.Config configPath string listener net.Listener clients map[net.Conn]*clientConn mu sync.Mutex } type clientConn struct { conn net.Conn server *Server reader *bufio.Reader username string authenticated bool cwd string dataHost string dataPort int pasvMode bool pasvListener net.Listener transferType string renameFrom string } // NewServer creates a new FTP server func NewServer(cfg *config.Config, configPath string) *Server { return &Server{ config: cfg, configPath: configPath, clients: make(map[net.Conn]*clientConn), } } // Start starts the FTP server func (s *Server) Start() error { addr := fmt.Sprintf("%s:%d", s.config.FTP.Host, s.config.FTP.Port) listener, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("FTP server listen error: %v", err) } s.listener = listener // Ensure root directory exists rootDir := s.config.FTP.RootDir if err := os.MkdirAll(rootDir, 0755); err != nil { return fmt.Errorf("failed to create root directory: %v", err) } log.Printf("FTP server listening on %s, root dir: %s", addr, rootDir) go s.acceptLoop() return nil } // Stop stops the FTP server func (s *Server) Stop() { if s.listener != nil { s.listener.Close() } s.mu.Lock() for conn, client := range s.clients { client.pasvListener.Close() conn.Close() } s.mu.Unlock() } func (s *Server) acceptLoop() { for { conn, err := s.listener.Accept() if err != nil { log.Printf("FTP accept error: %v", err) return } client := &clientConn{ conn: conn, server: s, reader: bufio.NewReader(conn), cwd: "/", transferType: "ASCII", } s.mu.Lock() s.clients[conn] = client s.mu.Unlock() log.Printf("FTP client connected: %s", conn.RemoteAddr()) client.sendResponse(220, "FTP Server Ready") go client.handle() } } func (c *clientConn) sendResponse(code int, message string) { fmt.Fprintf(c.conn, "%d %s\r\n", code, message) } func (c *clientConn) sendMultiResponse(code int, lines []string) { for i, line := range lines { if i == len(lines)-1 { fmt.Fprintf(c.conn, "%d %s\r\n", code, line) } else { fmt.Fprintf(c.conn, "%d-%s\r\n", code, line) } } } func (c *clientConn) handle() { defer func() { c.conn.Close() if c.pasvListener != nil { c.pasvListener.Close() } c.server.mu.Lock() delete(c.server.clients, c.conn) c.server.mu.Unlock() log.Printf("FTP client disconnected: %s", c.conn.RemoteAddr()) }() for { c.conn.SetDeadline(time.Now().Add(5 * time.Minute)) line, err := c.reader.ReadString('\n') if err != nil { if err != io.EOF { log.Printf("FTP read error: %v", err) } return } line = strings.TrimRight(line, "\r\n") if line == "" { continue } log.Printf("FTP [%s] %s", c.conn.RemoteAddr(), line) c.processCommand(line) } } func (c *clientConn) processCommand(line string) { parts := strings.SplitN(line, " ", 2) cmd := strings.ToUpper(parts[0]) var args string if len(parts) > 1 { args = parts[1] } switch cmd { case "USER": c.handleUSER(args) case "PASS": c.handlePASS(args) case "QUIT": c.handleQUIT() case "PWD": c.handlePWD() case "CWD": c.handleCWD(args) case "CDUP": c.handleCDUP() case "TYPE": c.handleTYPE(args) case "PASV": c.handlePASV() case "PORT": c.handlePORT(args) case "LIST": c.handleLIST(args) case "NLST": c.handleNLST(args) case "RETR": c.handleRETR(args) case "STOR": c.handleSTOR(args) case "DELE": c.handleDELE(args) case "MKD": c.handleMKD(args) case "RMD": c.handleRMD(args) case "RNFR": c.handleRNFR(args) case "RNTO": c.handleRNTO(args) case "SIZE": c.handleSIZE(args) case "SYST": c.sendResponse(215, "UNIX Type: L8") case "FEAT": c.handleFEAT() case "NOOP": c.sendResponse(200, "OK") case "OPTS": c.handleOPTS(args) default: c.sendResponse(502, fmt.Sprintf("Command %s not implemented", cmd)) } } func (c *clientConn) handleUSER(args string) { if args == "" { c.sendResponse(501, "Syntax error: USER ") return } c.username = args c.authenticated = false c.sendResponse(331, "Password required") } func (c *clientConn) handlePASS(args string) { if c.username == "" { c.sendResponse(503, "Login with USER first") return } user := c.server.config.GetFTPUser(c.username) if user != nil && user.Password == args { c.authenticated = true // Set home directory as CWD homeDir := user.HomeDir if homeDir == "" { homeDir = c.server.config.FTP.RootDir } c.cwd = "/" c.sendResponse(230, "Login successful") log.Printf("FTP user '%s' logged in from %s", c.username, c.conn.RemoteAddr()) } else { c.username = "" c.sendResponse(530, "Login incorrect") } } func (c *clientConn) requireAuth() bool { if !c.authenticated { c.sendResponse(530, "Please login first") return false } return true } func (c *clientConn) handleQUIT() { c.sendResponse(221, "Goodbye") c.conn.Close() } func (c *clientConn) handlePWD() { if !c.requireAuth() { return } c.sendResponse(257, fmt.Sprintf("\"%s\" is current directory", c.cwd)) } func (c *clientConn) handleCWD(args string) { if !c.requireAuth() { return } newPath := c.resolvePath(args) realPath := c.toRealPath(newPath) info, err := os.Stat(realPath) if err != nil || !info.IsDir() { c.sendResponse(550, "Directory not found") return } c.cwd = newPath c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd)) } func (c *clientConn) handleCDUP() { if !c.requireAuth() { return } if c.cwd != "/" { c.cwd = filepath.Dir(c.cwd) if c.cwd == "" { c.cwd = "/" } } c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd)) } func (c *clientConn) handleTYPE(args string) { args = strings.ToUpper(args) switch args { case "A", "A N": c.transferType = "ASCII" c.sendResponse(200, "Type set to ASCII") case "I", "L 8": c.transferType = "BINARY" c.sendResponse(200, "Type set to Binary") default: c.sendResponse(504, "Type not supported") } } func (c *clientConn) handlePASV() { if !c.requireAuth() { return } if c.pasvListener != nil { c.pasvListener.Close() } listener, err := net.Listen("tcp", "0.0.0.0:0") if err != nil { c.sendResponse(425, "Cannot open passive connection") return } c.pasvListener = listener c.pasvMode = true addr := listener.Addr().(*net.TCPAddr) hostParts := strings.Split(c.conn.LocalAddr().(*net.TCPAddr).IP.String(), ".") p1 := addr.Port / 256 p2 := addr.Port % 256 c.sendResponse(227, fmt.Sprintf("Entering Passive Mode (%s,%s,%s,%s,%d,%d)", hostParts[0], hostParts[1], hostParts[2], hostParts[3], p1, p2)) } func (c *clientConn) handlePORT(args string) { if !c.requireAuth() { return } parts := strings.Split(args, ",") if len(parts) != 6 { c.sendResponse(501, "Syntax error in PORT command") return } host := strings.Join(parts[0:4], ".") p1, _ := strconv.Atoi(parts[4]) p2, _ := strconv.Atoi(parts[5]) c.dataHost = host c.dataPort = p1*256 + p2 c.pasvMode = false c.sendResponse(200, "PORT command successful") } func (c *clientConn) getDataConn() (net.Conn, error) { if c.pasvMode && c.pasvListener != nil { conn, err := c.pasvListener.Accept() c.pasvListener.Close() c.pasvListener = nil return conn, err } addr := fmt.Sprintf("%s:%d", c.dataHost, c.dataPort) return net.DialTimeout("tcp", addr, 10*time.Second) } func (c *clientConn) handleLIST(args string) { if !c.requireAuth() { return } targetPath := args if targetPath == "" || targetPath == "-a" || targetPath == "-la" || targetPath == "-al" { targetPath = c.cwd } else if !strings.HasPrefix(targetPath, "/") { targetPath = c.resolvePath(targetPath) } realPath := c.toRealPath(targetPath) entries, err := os.ReadDir(realPath) if err != nil { c.sendResponse(550, "Directory listing failed") return } dataConn, err := c.getDataConn() if err != nil { c.sendResponse(425, "Cannot open data connection") return } defer dataConn.Close() c.sendResponse(150, "Opening data connection for directory listing") var lines []string for _, entry := range entries { info, _ := entry.Info() modTime := info.ModTime() perm := "-rwxr-xr-x" if info.IsDir() { perm = "drwxr-xr-x" } line := fmt.Sprintf("%s 1 ftp ftp %12d %s %s", perm, info.Size(), modTime.Format("Jan 02 15:04"), entry.Name(), ) lines = append(lines, line) } if len(lines) == 0 { dataConn.Write([]byte("")) } else { dataConn.Write([]byte(strings.Join(lines, "\r\n") + "\r\n")) } c.sendResponse(226, "Transfer complete") } func (c *clientConn) handleNLST(args string) { if !c.requireAuth() { return } targetPath := args if targetPath == "" { targetPath = c.cwd } realPath := c.toRealPath(c.resolvePath(targetPath)) entries, err := os.ReadDir(realPath) if err != nil { c.sendResponse(550, "Directory listing failed") return } dataConn, err := c.getDataConn() if err != nil { c.sendResponse(425, "Cannot open data connection") return } defer dataConn.Close() c.sendResponse(150, "Opening data connection") var names []string for _, entry := range entries { names = append(names, entry.Name()) } dataConn.Write([]byte(strings.Join(names, "\r\n") + "\r\n")) c.sendResponse(226, "Transfer complete") } func (c *clientConn) handleRETR(args string) { if !c.requireAuth() { return } filePath := c.resolvePath(args) realPath := c.toRealPath(filePath) file, err := os.Open(realPath) if err != nil { c.sendResponse(550, "File not found") return } defer file.Close() dataConn, err := c.getDataConn() if err != nil { c.sendResponse(425, "Cannot open data connection") return } defer dataConn.Close() c.sendResponse(150, "Opening data connection") written, err := io.Copy(dataConn, file) if err != nil { c.sendResponse(426, "Transfer aborted") return } log.Printf("FTP RETR %s (%d bytes)", filePath, written) c.sendResponse(226, "Transfer complete") } func (c *clientConn) handleSTOR(args string) { if !c.requireAuth() { return } // Check write permission user := c.server.config.GetFTPUser(c.username) if user == nil || !user.Write { c.sendResponse(550, "Permission denied") return } filePath := c.resolvePath(args) realPath := c.toRealPath(filePath) // Ensure parent directory exists os.MkdirAll(filepath.Dir(realPath), 0755) file, err := os.Create(realPath) if err != nil { c.sendResponse(550, "Cannot create file") return } defer file.Close() dataConn, err := c.getDataConn() if err != nil { c.sendResponse(425, "Cannot open data connection") return } defer dataConn.Close() c.sendResponse(150, "Opening data connection") written, err := io.Copy(file, dataConn) if err != nil { c.sendResponse(426, "Transfer aborted") return } log.Printf("FTP STOR %s (%d bytes)", filePath, written) c.sendResponse(226, "Transfer complete") } func (c *clientConn) handleDELE(args string) { if !c.requireAuth() { return } user := c.server.config.GetFTPUser(c.username) if user == nil || !user.Write { c.sendResponse(550, "Permission denied") return } filePath := c.resolvePath(args) realPath := c.toRealPath(filePath) if err := os.Remove(realPath); err != nil { c.sendResponse(550, "Delete failed") return } c.sendResponse(250, "File deleted") } func (c *clientConn) handleMKD(args string) { if !c.requireAuth() { return } user := c.server.config.GetFTPUser(c.username) if user == nil || !user.Write { c.sendResponse(550, "Permission denied") return } dirPath := c.resolvePath(args) realPath := c.toRealPath(dirPath) if err := os.MkdirAll(realPath, 0755); err != nil { c.sendResponse(550, "Cannot create directory") return } c.sendResponse(257, fmt.Sprintf("\"%s\" created", dirPath)) } func (c *clientConn) handleRMD(args string) { if !c.requireAuth() { return } user := c.server.config.GetFTPUser(c.username) if user == nil || !user.Write { c.sendResponse(550, "Permission denied") return } dirPath := c.resolvePath(args) realPath := c.toRealPath(dirPath) if err := os.RemoveAll(realPath); err != nil { c.sendResponse(550, "Cannot remove directory") return } c.sendResponse(250, "Directory removed") } func (c *clientConn) handleRNFR(args string) { if !c.requireAuth() { return } user := c.server.config.GetFTPUser(c.username) if user == nil || !user.Write { c.sendResponse(550, "Permission denied") return } filePath := c.resolvePath(args) realPath := c.toRealPath(filePath) if _, err := os.Stat(realPath); err != nil { c.sendResponse(550, "File not found") return } c.renameFrom = filePath c.sendResponse(350, "Ready for RNTO") } func (c *clientConn) handleRNTO(args string) { if !c.requireAuth() { return } if c.renameFrom == "" { c.sendResponse(503, "RNFR required first") return } fromPath := c.toRealPath(c.renameFrom) toPath := c.toRealPath(c.resolvePath(args)) if err := os.Rename(fromPath, toPath); err != nil { c.sendResponse(550, "Rename failed") return } c.sendResponse(250, "Renamed") c.renameFrom = "" } func (c *clientConn) handleSIZE(args string) { if !c.requireAuth() { return } filePath := c.resolvePath(args) realPath := c.toRealPath(filePath) info, err := os.Stat(realPath) if err != nil { c.sendResponse(550, "File not found") return } c.sendResponse(213, fmt.Sprintf("%d", info.Size())) } func (c *clientConn) handleFEAT() { features := []string{ "Features:", " PASV", " UTF8", " SIZE", } c.sendMultiResponse(211, features) } func (c *clientConn) handleOPTS(args string) { if strings.ToUpper(args) == "UTF8 ON" { c.sendResponse(200, "UTF8 set to on") } else { c.sendResponse(501, "Option not understood") } } // resolvePath resolves a relative path to an absolute path within the FTP root func (c *clientConn) resolvePath(path string) string { if strings.HasPrefix(path, "/") { return filepath.Clean(path) } return filepath.Clean(filepath.Join(c.cwd, path)) } // toRealPath converts an FTP virtual path to a real filesystem path func (c *clientConn) toRealPath(ftpPath string) string { user := c.server.config.GetFTPUser(c.username) rootDir := c.server.config.FTP.RootDir if user != nil && user.HomeDir != "" { rootDir = user.HomeDir } // Remove leading slash cleanPath := strings.TrimPrefix(ftpPath, "/") return filepath.Join(rootDir, cleanPath) }