Practical application — Build an FTP server from scratch
Note
This page uses two different API variants:
Synchronous API with classic
def
functions, usable in any context.Asynchronous API with
async def
functions, using an asynchronous framework to perform I/O operations.
All asynchronous API examples assume that you are using asyncio
,
but you can use a different library thanks to the asynchronous backend engine API.
TL;DR
Yes, I know, you will never need to create your own FTP server (unless you want your own service). However, it is still interesting to see the structure of such a model, based on a standardized communication protocol.
The File Transfer Protocol (as defined in RFC 959) is a good example of how to set up a server with precise rules.
We are not going to implement all the requests (that is not the point). The tutorial will show you how to set up the infrastructure and exploit all (or most) of the EasyNetwork library’s features.
The Communication Protocol
FTP requests and responses are transmitted as ASCII strings separated by a carriage return (\r\n
).
Let’s say we want to have two classes FTPRequest
and FTPReply
to manage them in our request handler.
FTPRequest
Object
An FTP client request consists of a command and, optionally, arguments separated by a space character.
First, we define the exhaustive list of available commands (c.f. RFC 959 (Section 4.1)):
1from __future__ import annotations
2
3from enum import StrEnum, auto
4
5
6class FTPCommand(StrEnum):
7 """This is an enumeration of all the commands defined in RFC 959.
8
9 If the client sends a command that is not one of these,
10 the server will reply a 500 error code.
11
12 If the server has not implemented one of these commands,
13 it will reply a 502 error code.
14 """
15
16 @staticmethod
17 def _generate_next_value_(
18 name: str,
19 start: int,
20 count: int,
21 last_values: list[str],
22 ) -> str:
23 assert name.isupper()
24 return name
25
26 ABOR = auto()
27 ACCT = auto()
28 ALLO = auto()
29 APPE = auto()
30 CDUP = auto()
31 CWD = auto()
32 DELE = auto()
33 HELP = auto()
34 LIST = auto()
35 MKD = auto()
36 MODE = auto()
37 NOOP = auto()
38 PASS = auto()
39 PASV = auto()
40 PORT = auto()
41 PWD = auto()
42 QUIT = auto()
43 REIN = auto()
44 REST = auto()
45 RETR = auto()
46 RMD = auto()
47 RNFR = auto()
48 RNTO = auto()
49 SITE = auto()
50 SMNT = auto()
51 STAT = auto()
52 STOR = auto()
53 STOU = auto()
54 STRU = auto()
55 SYST = auto()
56 TYPE = auto()
57 USER = auto()
Note
See enum
module documentation to understand the usage of auto
and _generate_next_value_()
.
Second, we define the FTPRequest
class that will be used:
1from __future__ import annotations
2
3from dataclasses import dataclass
4from typing import Any
5
6from ftp_command import FTPCommand
7
8
9@dataclass(frozen=True, match_args=True)
10class FTPRequest:
11 """Dataclass defining an FTP request"""
12
13 command: FTPCommand
14 """Command name."""
15
16 args: tuple[Any, ...]
17 """Command arguments sequence. May be empty."""
FTPReply
Object
An FTP reply consists of a three-digit number (transmitted as three alphanumeric characters) followed by some text.
1from __future__ import annotations
2
3from dataclasses import dataclass
4
5
6@dataclass(frozen=True)
7class FTPReply:
8 """Dataclass defining an FTP reply."""
9
10 code: int
11 """The reply code."""
12
13 message: str
14 """A line of text following the reply code."""
15
Use Converters To Handle Character Strings
The client will send a character string and expect a character string in return. StringLineSerializer
will handle this part,
but we have created our objects in order not to manipulate strings.
To remedy this, we will use converters to switch between our FTPRequest
/ FTPReply
objects and strings.
1from __future__ import annotations
2
3from easynetwork.converter import AbstractPacketConverter
4from easynetwork.exceptions import PacketConversionError
5
6from ftp_command import FTPCommand
7from ftp_reply import FTPReply
8from ftp_request import FTPRequest
9
10
11class FTPRequestConverter(AbstractPacketConverter[FTPRequest, str]):
12 """Converter to switch between FTPRequest objects and strings."""
13
14 def convert_to_dto_packet(self, obj: FTPRequest) -> str:
15 """Creates the string representation of the FTPRequest object
16
17 Not implemented.
18 """
19
20 raise NotImplementedError("Not needed in server side")
21
22 def create_from_dto_packet(self, packet: str) -> FTPRequest:
23 """Builds an FTPRequest object from a raw string
24
25 >>> c = FTPRequestConverter()
26 >>> c.create_from_dto_packet("NOOP")
27 FTPRequest(command=<FTPCommand.NOOP: 'NOOP'>, args=())
28 >>> c.create_from_dto_packet("qUiT")
29 FTPRequest(command=<FTPCommand.QUIT: 'QUIT'>, args=())
30 >>> c.create_from_dto_packet("STOR /path/file.txt")
31 FTPRequest(command=<FTPCommand.STOR: 'STOR'>, args=('/path/file.txt',))
32 >>> c.create_from_dto_packet("invalid command")
33 Traceback (most recent call last):
34 ...
35 easynetwork.exceptions.PacketConversionError: Command unrecognized: 'INVALID'
36
37 Parameters:
38 packet: The string representation of the request
39
40 Returns:
41 the FTP request
42 """
43 command, *args = packet.split(" ")
44 command = command.upper()
45 try:
46 command = FTPCommand(command)
47 except ValueError as exc:
48 raise PacketConversionError(f"Command unrecognized: {command!r}") from exc
49 return FTPRequest(command, tuple(args))
50
51
52class FTPReplyConverter(AbstractPacketConverter[FTPReply, str]):
53 """Converter to switch between FTPReply objects and strings."""
54
55 def convert_to_dto_packet(self, obj: FTPReply) -> str:
56 """Creates the string representation of the FTPReply object
57
58 >>> c = FTPReplyConverter()
59 >>> c.convert_to_dto_packet(FTPReply(200, "Command okay."))
60 '200 Command okay.'
61 >>> c.convert_to_dto_packet(FTPReply(10, "Does not exist but why not."))
62 '010 Does not exist but why not.'
63
64 Parameters:
65 obj: The FTPReply object
66
67 Returns:
68 the string representation of the reply
69 """
70
71 code: int = obj.code
72 message: str = obj.message
73
74 assert 0 <= code < 1000, f"Invalid reply code {code:d}"
75
76 # Multi-line replies exist, but we won't deal with them in this tutorial.
77 assert "\n" not in message, "message contains newline character"
78 separator = " "
79
80 return f"{code:03d}{separator}{message}"
81
82 def create_from_dto_packet(self, packet: str) -> FTPReply:
83 """Builds an FTPReply object from a raw string
84
85 Not implemented.
86 """
87 raise NotImplementedError("Not needed in server side")
Note
In FTPRequestConverter.create_from_dto_packet()
, the arguments are left as sent and returned.
45try:
46 command = FTPCommand(command)
47except ValueError as exc:
48 raise PacketConversionError(f"Command unrecognized: {command!r}") from exc
49return FTPRequest(command, tuple(args))
An improvement would be to process them here and not leave the job to the request handler. But since we are not building a real (complete and fully featured) FTP server, we will leave the code as is.
The Protocol Object
Now that we have our business objects, we can create our protocol object.
1from __future__ import annotations
2
3from easynetwork.converter import StapledPacketConverter
4from easynetwork.protocol import StreamProtocol
5from easynetwork.serializers import StringLineSerializer
6
7from ftp_converters import FTPReplyConverter, FTPRequestConverter
8from ftp_reply import FTPReply
9from ftp_request import FTPRequest
10
11
12class FTPServerProtocol(StreamProtocol[FTPReply, FTPRequest]):
13 def __init__(self) -> None:
14 request_converter = FTPRequestConverter()
15 response_converter = FTPReplyConverter()
16
17 super().__init__(
18 serializer=StringLineSerializer(newline="CRLF", encoding="ascii"),
19 converter=StapledPacketConverter(
20 sent_packet_converter=response_converter,
21 received_packet_converter=request_converter,
22 ),
23 )
Note
Note the use of StapledPacketConverter
:
17super().__init__(
18 serializer=StringLineSerializer(newline="CRLF", encoding="ascii"),
19 converter=StapledPacketConverter(
20 sent_packet_converter=response_converter,
21 received_packet_converter=request_converter,
22 ),
23)
It will create a composite converter with our two converters.
The Server
FTPReply
: Define Default Replies
A good way to reply to the client with default replies is to define them in methods.
Here are just a few that will be used in this tutorial.
6@dataclass(frozen=True)
7class FTPReply:
8 """Dataclass defining an FTP reply."""
9
10 code: int
11 """The reply code."""
12
13 message: str
14 """A line of text following the reply code."""
15
16 @staticmethod
17 def ok() -> FTPReply:
18 return FTPReply(200, "Command okay.")
19
20 @staticmethod
21 def service_ready_for_new_user() -> FTPReply:
22 return FTPReply(220, "Service ready for new user.")
23
24 @staticmethod
25 def connection_close(*, unexpected: bool = False) -> FTPReply:
26 if unexpected:
27 return FTPReply(421, "Service not available, closing control connection.")
28 return FTPReply(221, "Service closing control connection.")
29
30 @staticmethod
31 def syntax_error() -> FTPReply:
32 return FTPReply(500, "Syntax error, command unrecognized.")
33
34 @staticmethod
35 def not_implemented_error() -> FTPReply:
36 return FTPReply(502, "Command not implemented.")
The Request Handler
Let’s create this request handler.
Service Initialization
A feature we could have used for the echo client/server over TCP tutorial is to define actions to perform at start/end of the server.
Here, we’ll only initialize the logger, but we could also use it to prepare the folders and files that the server should handle (location, permissions, file existence, etc.).
16class FTPRequestHandler(AsyncStreamRequestHandler[FTPRequest, FTPReply]):
17 async def service_init(
18 self,
19 exit_stack: contextlib.AsyncExitStack,
20 server: Any,
21 ) -> None:
22 self.logger = logging.getLogger(self.__class__.__name__)
23
Control Connection Hooks
Here are the features brought by AsyncStreamRequestHandler
: It is possible to perform actions when connecting/disconnecting the client.
24async def on_connection(self, client: AsyncStreamClient[FTPReply]) -> None:
25 await client.send_packet(FTPReply.service_ready_for_new_user())
26
27async def on_disconnection(self, client: AsyncStreamClient[FTPReply]) -> None:
28 with contextlib.suppress(ConnectionError):
29 if not client.is_closing():
30 await client.send_packet(FTPReply.connection_close(unexpected=True))
31
The handle()
Method
Only NOOP
and QUIT
commands will be implemented for this tutorial. All parse errors are considered syntax errors.
32async def handle(
33 self,
34 client: AsyncStreamClient[FTPReply],
35) -> AsyncGenerator[None, FTPRequest]:
36 client_address = client.extra(INETClientAttribute.remote_address)
37 try:
38 request: FTPRequest = yield
39 except StreamProtocolParseError as exc:
40 self.logger.warning(
41 "%s: %s: %s",
42 client_address,
43 type(exc.error).__name__,
44 exc.error,
45 )
46 await client.send_packet(FTPReply.syntax_error())
47 return
48
49 self.logger.info("Sent by client %s: %s", client_address, request)
50 match request:
51 case FTPRequest(FTPCommand.NOOP):
52 await client.send_packet(FTPReply.ok())
53
54 case FTPRequest(FTPCommand.QUIT):
55 async with contextlib.aclosing(client):
56 await client.send_packet(FTPReply.connection_close())
57
58 case _:
59 await client.send_packet(FTPReply.not_implemented_error())
Full Code
1from __future__ import annotations
2
3import contextlib
4import logging
5from collections.abc import AsyncGenerator
6from typing import Any
7
8from easynetwork.api_async.server import AsyncStreamClient, AsyncStreamRequestHandler, INETClientAttribute
9from easynetwork.exceptions import StreamProtocolParseError
10
11from ftp_command import FTPCommand
12from ftp_reply import FTPReply
13from ftp_request import FTPRequest
14
15
16class FTPRequestHandler(AsyncStreamRequestHandler[FTPRequest, FTPReply]):
17 async def service_init(
18 self,
19 exit_stack: contextlib.AsyncExitStack,
20 server: Any,
21 ) -> None:
22 self.logger = logging.getLogger(self.__class__.__name__)
23
24 async def on_connection(self, client: AsyncStreamClient[FTPReply]) -> None:
25 await client.send_packet(FTPReply.service_ready_for_new_user())
26
27 async def on_disconnection(self, client: AsyncStreamClient[FTPReply]) -> None:
28 with contextlib.suppress(ConnectionError):
29 if not client.is_closing():
30 await client.send_packet(FTPReply.connection_close(unexpected=True))
31
32 async def handle(
33 self,
34 client: AsyncStreamClient[FTPReply],
35 ) -> AsyncGenerator[None, FTPRequest]:
36 client_address = client.extra(INETClientAttribute.remote_address)
37 try:
38 request: FTPRequest = yield
39 except StreamProtocolParseError as exc:
40 self.logger.warning(
41 "%s: %s: %s",
42 client_address,
43 type(exc.error).__name__,
44 exc.error,
45 )
46 await client.send_packet(FTPReply.syntax_error())
47 return
48
49 self.logger.info("Sent by client %s: %s", client_address, request)
50 match request:
51 case FTPRequest(FTPCommand.NOOP):
52 await client.send_packet(FTPReply.ok())
53
54 case FTPRequest(FTPCommand.QUIT):
55 async with contextlib.aclosing(client):
56 await client.send_packet(FTPReply.connection_close())
57
58 case _:
59 await client.send_packet(FTPReply.not_implemented_error())
Start The Server
1from __future__ import annotations
2
3from collections.abc import Sequence
4
5from easynetwork.api_sync.server import StandaloneTCPNetworkServer
6
7from ftp_reply import FTPReply
8from ftp_request import FTPRequest
9from ftp_server_protocol import FTPServerProtocol
10from ftp_server_request_handler import FTPRequestHandler
11
12
13class FTPServer(StandaloneTCPNetworkServer[FTPRequest, FTPReply]):
14 def __init__(
15 self,
16 host: str | Sequence[str] | None = None,
17 port: int = 21000,
18 ) -> None:
19 super().__init__(host, port, FTPServerProtocol(), FTPRequestHandler())
20
21
22if __name__ == "__main__":
23 import logging
24
25 def main() -> None:
26 logging.basicConfig(
27 level=logging.INFO,
28 format="[ %(levelname)s ] [ %(name)s ] %(message)s",
29 )
30 with FTPServer() as server:
31 try:
32 server.serve_forever()
33 except KeyboardInterrupt:
34 pass
35
36 main()
1from __future__ import annotations
2
3from collections.abc import Sequence
4
5from easynetwork.api_async.server import AsyncTCPNetworkServer
6
7from ftp_reply import FTPReply
8from ftp_request import FTPRequest
9from ftp_server_protocol import FTPServerProtocol
10from ftp_server_request_handler import FTPRequestHandler
11
12
13class AsyncFTPServer(AsyncTCPNetworkServer[FTPRequest, FTPReply]):
14 def __init__(
15 self,
16 host: str | Sequence[str] | None = None,
17 port: int = 21000,
18 ) -> None:
19 super().__init__(host, port, FTPServerProtocol(), FTPRequestHandler())
20
21
22if __name__ == "__main__":
23 import asyncio
24 import logging
25
26 async def main() -> None:
27 logging.basicConfig(
28 level=logging.INFO,
29 format="[ %(levelname)s ] [ %(name)s ] %(message)s",
30 )
31 async with AsyncFTPServer() as server:
32 try:
33 await server.serve_forever()
34 except asyncio.CancelledError:
35 pass
36
37 asyncio.run(main())
Outputs
The output of the example should look something like this:
Server:
(.venv) $ python server.py
[ INFO ] [ easynetwork.api_async.server.tcp ] Start serving at ('::', 21000), ('0.0.0.0', 21000)
[ INFO ] [ easynetwork.api_async.server.tcp ] Accepted new connection (address = ('127.0.0.1', 45994))
[ INFO ] [ FTPRequestHandler ] Sent by client ('127.0.0.1', 45994): FTPRequest(command=<FTPCommand.NOOP: 'NOOP'>, args=())
[ INFO ] [ FTPRequestHandler ] Sent by client ('127.0.0.1', 45994): FTPRequest(command=<FTPCommand.NOOP: 'NOOP'>, args=())
[ INFO ] [ FTPRequestHandler ] Sent by client ('127.0.0.1', 45994): FTPRequest(command=<FTPCommand.STOR: 'STOR'>, args=('/path/to/file.txt',))
[ WARNING ] [ FTPRequestHandler ] ('127.0.0.1', 45994): PacketConversionError: Command unrecognized: 'UNKNOWN'
[ INFO ] [ FTPRequestHandler ] Sent by client ('127.0.0.1', 45994): FTPRequest(command=<FTPCommand.QUIT: 'QUIT'>, args=())
[ INFO ] [ easynetwork.api_async.server.tcp ] ('127.0.0.1', 45994) disconnected
(.venv) $ python server.py
[ INFO ] [ easynetwork.api_async.server.tcp ] Start serving at ('::', 21000), ('0.0.0.0', 21000)
[ INFO ] [ easynetwork.api_async.server.tcp ] Accepted new connection (address = ('::1', 45994))
[ INFO ] [ FTPRequestHandler ] Sent by client ('::1', 45994): FTPRequest(command=<FTPCommand.NOOP: 'NOOP'>, args=())
[ INFO ] [ FTPRequestHandler ] Sent by client ('::1', 45994): FTPRequest(command=<FTPCommand.NOOP: 'NOOP'>, args=())
[ INFO ] [ FTPRequestHandler ] Sent by client ('::1', 45994): FTPRequest(command=<FTPCommand.STOR: 'STOR'>, args=('/path/to/file.txt',))
[ WARNING ] [ FTPRequestHandler ] ('::1', 45994): PacketConversionError: Command unrecognized: 'UNKNOWN'
[ INFO ] [ FTPRequestHandler ] Sent by client ('::1', 45994): FTPRequest(command=<FTPCommand.QUIT: 'QUIT'>, args=())
[ INFO ] [ easynetwork.api_async.server.tcp ] ('::1', 45994) disconnected
Client:
Note
The File Transfer Protocol is based on the Telnet protocol.
The telnet(1) command is used to communicate with another host using the Telnet protocol.
$ telnet -4 localhost 21000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 Service ready for new user.
NOOP
200 Command okay.
nOoP
200 Command okay.
STOR /path/to/file.txt
502 Command not implemented.
UNKNOWN command
500 Syntax error, command unrecognized.
QUIT
221 Service closing control connection.
Connection closed by foreign host.
$ telnet -6 localhost 21000
Trying ::1...
Connected to localhost.
Escape character is '^]'.
220 Service ready for new user.
NOOP
200 Command okay.
nOoP
200 Command okay.
STOR /path/to/file.txt
502 Command not implemented.
UNKNOWN command
500 Syntax error, command unrecognized.
QUIT
221 Service closing control connection.
Connection closed by foreign host.