From e4144f4b19c5acb93444791aa9f789a0fe54b65c Mon Sep 17 00:00:00 2001 From: nat Date: Sun, 10 Mar 2024 13:33:25 -0700 Subject: [PATCH] init --- .gitignore | 1 + README.md | 27 +++++++ client.py | 155 +++++++++++++++++++++++++++++++++++++++++ ftp_utils.py | 22 ++++++ server.py | 120 +++++++++++++++++++++++++++++++ server/binary-file.ico | Bin 0 -> 1150 bytes 6 files changed, 325 insertions(+) create mode 100644 .gitignore create mode 100755 README.md create mode 100755 client.py create mode 100755 ftp_utils.py create mode 100755 server.py create mode 100755 server/binary-file.ico diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100755 index 0000000..2572e46 --- /dev/null +++ b/README.md @@ -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 ` 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 `, used as a part of `GET ` 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 ` + +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. diff --git a/client.py b/client.py new file mode 100755 index 0000000..82f8f02 --- /dev/null +++ b/client.py @@ -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 '") + +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 '") + +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() diff --git a/ftp_utils.py b/ftp_utils.py new file mode 100755 index 0000000..67f31d2 --- /dev/null +++ b/ftp_utils.py @@ -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) diff --git a/server.py b/server.py new file mode 100755 index 0000000..356c001 --- /dev/null +++ b/server.py @@ -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() diff --git a/server/binary-file.ico b/server/binary-file.ico new file mode 100755 index 0000000000000000000000000000000000000000..cac6f1daf2c4d3f5f7963df7f591a538702137ea GIT binary patch literal 1150 zcmbtTO-NKx7``>D1|>*j4pH`vvwos&Y(qL4DQMlpH5jcVHOiQ4i*tc z=0YfNA({;YMaW2Di#A0u#*~t16BW2Vo$ucFWX6nw?r`3B-}&x$=bZ1}r_>?-ii;JW zM^yN*QkGKc7(t4R;Tcqjx!;EuN(G4%#PNL)&V`$kcUf-{3WbH!C@46~`Dq6y8BY;s z_CYww$+})=9$?JsJ&KBk(bMx7v$O9I4nKjl^CoOucAmYPy2?`Fq&jUdQ6%d#7IfNd2a+S852BWuviifcc2lF15x(1l863sI9e8 zQgVUoMZfgH{r@;To&j`sKca>;9lc)hZuDDL6t?{gvDg~g+U`1g=GTk=)zufSeKa)O z-m5qMKmFDJFGJvi8Z{`txIPfaDTvGImJ zf9&;7&ab(71WQW~wEtu>&AT$reRJz$`Wuk*?dTZR_eDI*oe=nZHd-`