702 라인
14 KiB
Go
702 라인
14 KiB
Go
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 <username>")
|
|
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)
|
|
}
|