This commit is contained in:
Nat 2024-03-10 13:33:25 -07:00
commit e4144f4b19
Signed by: nat
GPG Key ID: B53AB05285D710D6
6 changed files with 325 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

27
README.md Executable file
View File

@ -0,0 +1,27 @@
# Simple "FTP" Server
Putting "FTP" in quotes here because this certainly does not conform to the FTP spec!
This project implements a TCP server (`server.py`) and a client program that can issue a few commands to it (`client.py`). This isn't meant to be generally useful; with some very basic modifications it should actually be able to run on two different devices, but as is the server and client both need to be run on localhost. It was, however, a fun project, and one of the few complete projects I'm able to share that shows I'm comfortable using Python, so I figured I'd upload it.
The client program recognizes the following commands:
```
OPEN [port number] Establish connection to server at 127.0.0.1:[port number]
CLOSE Close the current connection
QUIT Close connection (if open) and exit
GET [file name] Download 'file_name' from the server ("./server")
PUT [file name] Upload 'file_name' from the client ("./client")
```
## Notes
The server handles these commands a bit differently (e.g. when you execute `PUT file` it actually translates it to `PUT <file> <size in bytes>` automatically so the server knows how much it's getting; you cannot, however, run the full command on your own). The server also has the extra command `SIZE <file>`, used as a part of `GET <file>` to negotiate the transfer, but the client won't recognize it if you were to execute it during a session.
Commands are case-insensitive
You can start the server on another port by passing it an argument (i.e. `python server.py 8080`). You could also start more than one server that way, but note that they'll both use the same `./server/` directory.
The client will attempt to autoconnect to `127.0.0.1:12000`. If you haven't already started the server without specifying a port, the connection will be refused and you can try again by running `open <port>`
Files may be within sub-directories; the full path will be created where it lands.
The server has a configurable max file size (64KiB). You can configure it to be less, but if you can't set it to anything higher than 64KiB. If you do, the server will send at most 64KiB.

155
client.py Executable file
View File

@ -0,0 +1,155 @@
from socket import *
import sys
import os
from enum import Enum
from pathlib import Path
from ftp_utils import *
import traceback
HOST = '127.0.0.1'
PORT = 12000
AUTOCONNECT = True
sock = None
def require_sock():
global sock
if sock is None:
raise FTPError("Not connected! Try connecting to a server by running 'open <port>'")
def ftp_client_OPEN(*args):
global sock
port = get_argument(args, "port", 0)
try:
print(f"Establishing connection to {HOST}:{port}")
sock = socket(AF_INET, SOCK_STREAM)
sock.connect((HOST, int(port)))
return (None, None)
except TypeError:
raise FTPError("No port specified")
except ValueError:
raise FTPError("Invalid port number. Ports must be given as an integer")
except ConnectionRefusedError:
raise FTPError(f"Connection on {HOST}:{port} refused")
except OverflowError:
raise FTPError("A valid port is between 0 and 65535", 7783)
def ftp_client_CLOSE(*args):
global sock
resp = fetch("CLOSE")
if resp == "bye<3":
print("Closing connection")
sock.close()
sock = None
else:
raise FTPError("Failed to close connection")
def ftp_client_QUIT(*args):
global sock
if sock is not None:
# We don't bother checking if the server closed
# the connection; we're leaving, and if they can't
# handle it, that's their problem!
_resp = fetch("CLOSE")
print("Closing connection")
sock.close()
print("Exiting...")
sys.exit(0)
def ftp_client_GET(*args):
global sock
require_sock()
file_name = get_argument(args, "file name", 0)
file_size = int(fetch(f"SIZE {file_name}"))
parent_path = os.path.join("./client", str(Path(file_name).parent))
if not os.path.exists(parent_path):
os.makedirs(parent_path)
print(f"Downloading {file_name}")
resp = fetch_bytes(f"GET {file_name}", file_size)
with open(os.path.join("client", file_name), "wb") as new_file:
new_file.write(resp)
print(f"File written to {os.path.join('./client', file_name)}")
def ftp_client_PUT(*args):
global sock
require_sock()
file_name = get_argument(args, "file name", 0)
try:
file_size = os.path.getsize(os.path.join("client", file_name))
except FileNotFoundError:
raise FTPError("File not found!", 8341)
ack = fetch(f"PUT {file_name} {file_size}")
if ack != "OK":
raise FTPError(f"Server unwilling to accept file '{file_name}'", 2377)
with open(os.path.join("./client", file_name), "rb") as fetched_file:
file_bytes = fetched_file.read()
sock.sendall(file_bytes)
print(f"File uploaded to ./client/{file_name}")
CLIENT_COMMANDS = {
"OPEN": ftp_client_OPEN,
"CLOSE": ftp_client_CLOSE,
"QUIT": ftp_client_QUIT,
"GET": ftp_client_GET,
"PUT": ftp_client_PUT,
}
def fetch_bytes(request, amount=1024):
global sock
require_sock()
sock.sendall(request.encode("utf-8"))
return sock.recv(amount)
def fetch(request):
message = fetch_bytes(request).decode("utf-8")
tokens = message.split()
if message.split()[0] == "Error":
raise FTPError(" ".join(tokens[2:]), int(tokens[1][0:-1]))
return message
FTP_Client_Outcome = Enum("FTP_Client_Outcome", [])
# set up the tcp socket
if AUTOCONNECT:
try:
ftp_client_OPEN(PORT)
except FTPError as e:
print(f"Error {e.code}: {e.message}")
print("You may reattempt a connection by running 'OPEN <port>'")
try:
while (True):
s = input("Message: ")
command_name, arguments = parse_ftp_command(s)
try:
CLIENT_COMMANDS[command_name](*arguments)
except KeyError:
print(f"Error: Unknown command {command_name}")
continue
except FTPError as e:
print(f"Error {e.code}: {e.message}")
except KeyboardInterrupt:
ftp_client_QUIT()
except Exception as e:
print(f"Fatal Error: {e}")
traceback.print_exc()
finally:
if sock is not None:
sock.close()

22
ftp_utils.py Executable file
View File

@ -0,0 +1,22 @@
class FTPError(Exception):
def __init__(self, message="An unknown error occured", code=-1):
self.message = message
self.code = code
super().__init__(self.message)
def parse_ftp_command(cmd):
tokens = cmd.split(" ")
if len(tokens) == 0:
raise FTPError("No command issued!")
command_name = tokens[0].upper()
arguments = tokens[1:]
return (command_name, arguments)
def get_argument(args, name, index):
try:
return args[index]
except IndexError:
raise FTPError(f"Missing argument '{name}'", 2138)

120
server.py Executable file
View File

@ -0,0 +1,120 @@
from socket import *
from ftp_utils import *
from enum import Enum
from time import sleep
from pathlib import Path
import os
import sys
import traceback
HOST = '127.0.0.1'
PORT = 12000
if len(sys.argv) > 1:
PORT = int(sys.argv[1])
DEBUG = True
MAX_FILE_SIZE = 65536 # 64 KiB
# set up the tcp sockets
sock = socket(AF_INET, SOCK_STREAM)
conn = None # for now...
if DEBUG:
# Allows for quicker debugging at risk of stray
# packets messing with the byte stream
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.bind((HOST, PORT))
sock.listen()
def ftp_GET(*args):
global conn
file_name = get_argument(args, "file name", 0)
with open(os.path.join("./server", file_name), "rb") as fetched_file:
file_bytes = fetched_file.read()
conn.sendall(file_bytes)
def ftp_PUT(*args):
file_name = get_argument(args, "file name", 0)
size = int(get_argument(args, "file size", 1))
if size > MAX_FILE_SIZE:
throw_error(conn, 3884, f"File exceeds maximum file size: {MAX_FILE_SIZE}")
return
conn.sendall("OK".encode("utf-8"))
file_bytes = conn.recv(size)
parent_path = os.path.join("./server", str(Path(file_name).parent))
if not os.path.exists(parent_path):
os.makedirs(parent_path)
with open(os.path.join("server", file_name), "wb") as new_file:
new_file.write(file_bytes)
print(f"File '{file_name}' uploaded")
def ftp_CLOSE(*args):
global conn
send_message(conn, "bye<3")
conn.close()
conn, addr = sock.accept()
def ftp_SIZE(*args):
global conn
file_name = get_argument(args, "file name", 0)
try:
send_message(conn, str(os.path.getsize(os.path.join("./server", file_name))))
except FileNotFoundError:
raise FTPError(f"File '{file_name}' not found", 9283)
COMMANDS = {
"GET": ftp_GET,
"PUT": ftp_PUT,
"CLOSE": ftp_CLOSE,
"SIZE": ftp_SIZE,
}
def throw_error(conn, code, msg):
conn.sendall(f"Error {code}: {msg}".encode("utf-8"))
def send_message(conn, msg):
conn.sendall(msg.encode("utf-8"))
# listen for a connection
conn, addr = sock.accept()
print("Connected to " , addr)
try:
while (True):
data = conn.recv(1024).decode("utf-8")
command_name, arguments = parse_ftp_command(data)
# Let's allow for case insensitivity in the command name
command_name = command_name.upper()
try:
result = COMMANDS[command_name](*arguments)
except KeyError:
throw_error(conn, 2, f"Unknown command '{command_name}'")
continue
except FTPError as err:
throw_error(conn, err.code, err.message)
except Exception as err:
print(f"Error: {err}")
traceback.print_exc()
throw_error(conn, -1, f"Unknown error while executing {command_name}")
except BrokenPipeError:
print("Broken Pipe. Awaitng new connections...")
conn, addr = sock.accept()
except Exception as e:
print(f"Error: {e}")
traceback.print_exc()
finally:
conn.close()
sock.close()

BIN
server/binary-file.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB