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)):

ftp_command.py
 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:

ftp_request.py
 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.

ftp_reply.py
 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.

ftp_converters.py
 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.

ftp_server_protocol.py
 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.

ftp_reply.py
 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

ftp_server_request_handler.py
 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

server.py
 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()

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

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.