404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""
|
|
FTP Session Handler - Manages individual client connections
|
|
"""
|
|
|
|
import socket
|
|
import threading
|
|
import os
|
|
import time
|
|
import random
|
|
from config import FTP_RESPONSES, USERS, SERVER_ROOT, DATA_PORT_RANGE
|
|
|
|
|
|
class FTPSession:
|
|
def __init__(self, client_socket, client_address):
|
|
self.client_socket = client_socket
|
|
self.client_address = client_address
|
|
self.data_socket = None
|
|
self.data_address = None
|
|
self.passive_socket = None
|
|
|
|
# Session state
|
|
self.authenticated = False
|
|
self.username = None
|
|
self.current_directory = SERVER_ROOT
|
|
self.transfer_mode = 'I' # Binary mode by default
|
|
|
|
# Ensure server root directory exists
|
|
if not os.path.exists(SERVER_ROOT):
|
|
os.makedirs(SERVER_ROOT)
|
|
|
|
print(f"New FTP session started for {client_address}")
|
|
|
|
def send_response(self, code, message=None):
|
|
"""Send FTP response to client"""
|
|
if message is None:
|
|
response = FTP_RESPONSES.get(code, f"{code} Unknown response code.")
|
|
else:
|
|
response = f"{code} {message}"
|
|
|
|
try:
|
|
self.client_socket.send((response + '\r\n').encode('utf-8'))
|
|
print(f"Sent to {self.client_address}: {response}")
|
|
except Exception as e:
|
|
print(f"Error sending response to {self.client_address}: {e}")
|
|
|
|
def handle_session(self):
|
|
"""Main session handler"""
|
|
try:
|
|
# Send welcome message
|
|
self.send_response('220', 'FTP Server Ready')
|
|
|
|
while True:
|
|
try:
|
|
# Receive command from client
|
|
data = self.client_socket.recv(1024).decode('utf-8').strip()
|
|
if not data:
|
|
break
|
|
|
|
print(f"Received from {self.client_address}: {data}")
|
|
|
|
# Parse command
|
|
parts = data.split(' ', 1)
|
|
command = parts[0].upper()
|
|
args = parts[1] if len(parts) > 1 else ''
|
|
|
|
# Handle command
|
|
self.handle_command(command, args)
|
|
|
|
except ConnectionResetError:
|
|
print(f"Client {self.client_address} disconnected")
|
|
break
|
|
except Exception as e:
|
|
print(f"Error handling command from {self.client_address}: {e}")
|
|
self.send_response('500', 'Internal server error')
|
|
|
|
except Exception as e:
|
|
print(f"Session error for {self.client_address}: {e}")
|
|
finally:
|
|
self.cleanup()
|
|
|
|
def handle_command(self, command, args):
|
|
"""Route commands to appropriate handlers"""
|
|
if command == 'USER':
|
|
self.handle_user(args)
|
|
elif command == 'PASS':
|
|
self.handle_pass(args)
|
|
elif command == 'QUIT':
|
|
self.handle_quit()
|
|
elif command == 'PWD':
|
|
self.handle_pwd()
|
|
elif command == 'CWD':
|
|
self.handle_cwd(args)
|
|
elif command == 'LIST' or command == 'NLST':
|
|
self.handle_list(command)
|
|
elif command == 'RETR':
|
|
self.handle_retr(args)
|
|
elif command == 'PASV':
|
|
self.handle_pasv()
|
|
elif command == 'PORT':
|
|
self.handle_port(args)
|
|
elif command == 'TYPE':
|
|
self.handle_type(args)
|
|
elif command == 'SYST':
|
|
self.handle_syst()
|
|
elif command == 'NOOP':
|
|
self.send_response('200')
|
|
else:
|
|
self.send_response('500', f'Command {command} not recognized')
|
|
|
|
def handle_user(self, username):
|
|
"""Handle USER command"""
|
|
if not username:
|
|
self.send_response('501', 'Username required')
|
|
return
|
|
|
|
self.username = username
|
|
self.authenticated = False
|
|
|
|
if username in USERS:
|
|
self.send_response('331', f'User {username} OK. Password required')
|
|
else:
|
|
self.send_response('331', 'Password required') # Don't reveal if user exists
|
|
|
|
def handle_pass(self, password):
|
|
"""Handle PASS command"""
|
|
if not self.username:
|
|
self.send_response('503', 'Login with USER first')
|
|
return
|
|
|
|
if not password:
|
|
self.send_response('501', 'Password required')
|
|
return
|
|
|
|
if self.username in USERS and USERS[self.username] == password:
|
|
self.authenticated = True
|
|
self.send_response('230', f'User {self.username} logged in')
|
|
else:
|
|
self.authenticated = False
|
|
self.send_response('530', 'Login incorrect')
|
|
|
|
def handle_quit(self):
|
|
"""Handle QUIT command"""
|
|
self.send_response('221', 'Goodbye')
|
|
self.client_socket.close()
|
|
return True # Signal to exit the session loop
|
|
|
|
def handle_pwd(self):
|
|
"""Handle PWD command"""
|
|
if not self.authenticated:
|
|
self.send_response('530', 'Not logged in')
|
|
return
|
|
|
|
# Return relative path from server root
|
|
rel_path = os.path.relpath(self.current_directory, SERVER_ROOT)
|
|
if rel_path == '.':
|
|
rel_path = '/'
|
|
else:
|
|
rel_path = '/' + rel_path.replace('\\', '/')
|
|
|
|
self.send_response('257', f'"{rel_path}" is current directory')
|
|
|
|
def handle_cwd(self, path):
|
|
"""Handle CWD command"""
|
|
if not self.authenticated:
|
|
self.send_response('530', 'Not logged in')
|
|
return
|
|
|
|
if not path:
|
|
self.send_response('501', 'Path required')
|
|
return
|
|
|
|
# Handle absolute and relative paths
|
|
if path.startswith('/'):
|
|
new_path = os.path.join(SERVER_ROOT, path.lstrip('/'))
|
|
else:
|
|
new_path = os.path.join(self.current_directory, path)
|
|
|
|
new_path = os.path.normpath(new_path)
|
|
|
|
# Security check: ensure path is within server root
|
|
if not new_path.startswith(SERVER_ROOT):
|
|
self.send_response('550', 'Permission denied')
|
|
return
|
|
|
|
if os.path.exists(new_path) and os.path.isdir(new_path):
|
|
self.current_directory = new_path
|
|
self.send_response('250', 'Directory changed')
|
|
else:
|
|
self.send_response('550', 'Directory not found')
|
|
|
|
def handle_syst(self):
|
|
"""Handle SYST command"""
|
|
self.send_response('215', 'UNIX Type: L8')
|
|
|
|
def handle_type(self, type_arg):
|
|
"""Handle TYPE command"""
|
|
if not self.authenticated:
|
|
self.send_response('530', 'Not logged in')
|
|
return
|
|
|
|
if type_arg.upper() in ['I', 'A']:
|
|
self.transfer_mode = type_arg.upper()
|
|
self.send_response('200', f'Type set to {type_arg.upper()}')
|
|
else:
|
|
self.send_response('501', 'Type not supported')
|
|
|
|
def handle_pasv(self):
|
|
"""Handle PASV command - Enter passive mode"""
|
|
if not self.authenticated:
|
|
self.send_response('530', 'Not logged in')
|
|
return
|
|
|
|
try:
|
|
# Close existing passive socket if any
|
|
if self.passive_socket:
|
|
self.passive_socket.close()
|
|
|
|
# Create new passive socket
|
|
self.passive_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.passive_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
|
|
# Find available port
|
|
for port in range(DATA_PORT_RANGE[0], DATA_PORT_RANGE[1]):
|
|
try:
|
|
self.passive_socket.bind(('127.0.0.1', port))
|
|
break
|
|
except OSError:
|
|
continue
|
|
else:
|
|
self.send_response('425', 'Cannot open passive connection')
|
|
return
|
|
|
|
self.passive_socket.listen(1)
|
|
|
|
# Calculate port representation for PASV response
|
|
p1 = port // 256
|
|
p2 = port % 256
|
|
|
|
self.send_response('227', f'Entering Passive Mode (127,0,0,1,{p1},{p2})')
|
|
|
|
except Exception as e:
|
|
print(f"Error setting up passive mode: {e}")
|
|
self.send_response('425', 'Cannot open passive connection')
|
|
|
|
def handle_port(self, args):
|
|
"""Handle PORT command - Active mode"""
|
|
if not self.authenticated:
|
|
self.send_response('530', 'Not logged in')
|
|
return
|
|
|
|
try:
|
|
# Parse PORT command arguments
|
|
parts = args.split(',')
|
|
if len(parts) != 6:
|
|
self.send_response('501', 'Invalid PORT command')
|
|
return
|
|
|
|
# Extract IP and port
|
|
ip = '.'.join(parts[:4])
|
|
port = int(parts[4]) * 256 + int(parts[5])
|
|
|
|
self.data_address = (ip, port)
|
|
self.send_response('200', 'PORT command successful')
|
|
|
|
except Exception as e:
|
|
print(f"Error parsing PORT command: {e}")
|
|
self.send_response('501', 'Invalid PORT command')
|
|
|
|
def establish_data_connection(self):
|
|
"""Establish data connection (passive or active mode)"""
|
|
try:
|
|
if self.passive_socket:
|
|
# Passive mode
|
|
self.data_socket, _ = self.passive_socket.accept()
|
|
return True
|
|
elif self.data_address:
|
|
# Active mode
|
|
self.data_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.data_socket.connect(self.data_address)
|
|
return True
|
|
else:
|
|
return False
|
|
except Exception as e:
|
|
print(f"Error establishing data connection: {e}")
|
|
return False
|
|
|
|
def close_data_connection(self):
|
|
"""Close data connection"""
|
|
try:
|
|
if self.data_socket:
|
|
self.data_socket.close()
|
|
self.data_socket = None
|
|
except:
|
|
pass
|
|
|
|
def handle_list(self, command):
|
|
"""Handle LIST/NLST commands"""
|
|
if not self.authenticated:
|
|
self.send_response('530', 'Not logged in')
|
|
return
|
|
|
|
try:
|
|
self.send_response('150', 'Opening data connection for directory list')
|
|
|
|
if not self.establish_data_connection():
|
|
self.send_response('425', 'Cannot open data connection')
|
|
return
|
|
|
|
# Get directory listing
|
|
try:
|
|
files = os.listdir(self.current_directory)
|
|
listing = []
|
|
|
|
for filename in files:
|
|
filepath = os.path.join(self.current_directory, filename)
|
|
stat = os.stat(filepath)
|
|
|
|
if command == 'LIST':
|
|
# Detailed listing
|
|
is_dir = 'd' if os.path.isdir(filepath) else '-'
|
|
permissions = 'rwxr-xr-x' # Simplified permissions
|
|
size = stat.st_size
|
|
mtime = time.strftime('%b %d %H:%M', time.localtime(stat.st_mtime))
|
|
listing.append(f'{is_dir}{permissions} 1 user user {size:8} {mtime} {filename}')
|
|
else:
|
|
# Simple listing (NLST)
|
|
listing.append(filename)
|
|
|
|
# Send listing
|
|
data = '\r\n'.join(listing) + '\r\n'
|
|
self.data_socket.send(data.encode('utf-8'))
|
|
|
|
self.close_data_connection()
|
|
self.send_response('226', 'Directory listing completed')
|
|
|
|
except Exception as e:
|
|
print(f"Error reading directory: {e}")
|
|
self.close_data_connection()
|
|
self.send_response('550', 'Directory listing failed')
|
|
|
|
except Exception as e:
|
|
print(f"Error in LIST command: {e}")
|
|
self.send_response('425', 'Cannot open data connection')
|
|
|
|
def handle_retr(self, filename):
|
|
"""Handle RETR command - Download file"""
|
|
if not self.authenticated:
|
|
self.send_response('530', 'Not logged in')
|
|
return
|
|
|
|
if not filename:
|
|
self.send_response('501', 'Filename required')
|
|
return
|
|
|
|
# Construct file path
|
|
filepath = os.path.join(self.current_directory, filename)
|
|
filepath = os.path.normpath(filepath)
|
|
|
|
# Security check
|
|
if not filepath.startswith(SERVER_ROOT):
|
|
self.send_response('550', 'Permission denied')
|
|
return
|
|
|
|
if not os.path.exists(filepath) or not os.path.isfile(filepath):
|
|
self.send_response('550', 'File not found')
|
|
return
|
|
|
|
try:
|
|
self.send_response('150', f'Opening data connection for {filename}')
|
|
|
|
if not self.establish_data_connection():
|
|
self.send_response('425', 'Cannot open data connection')
|
|
return
|
|
|
|
# Send file
|
|
with open(filepath, 'rb') as f:
|
|
while True:
|
|
data = f.read(8192)
|
|
if not data:
|
|
break
|
|
self.data_socket.send(data)
|
|
|
|
self.close_data_connection()
|
|
self.send_response('226', 'File transfer completed')
|
|
|
|
except Exception as e:
|
|
print(f"Error transferring file {filename}: {e}")
|
|
self.close_data_connection()
|
|
self.send_response('550', 'File transfer failed')
|
|
|
|
def cleanup(self):
|
|
"""Clean up session resources"""
|
|
try:
|
|
if self.data_socket:
|
|
self.data_socket.close()
|
|
if self.passive_socket:
|
|
self.passive_socket.close()
|
|
if self.client_socket:
|
|
self.client_socket.close()
|
|
except:
|
|
pass
|
|
|
|
print(f"Session ended for {self.client_address}")
|