FTPサーバをPythonの標準ライブラリで実装

# はじめに

この記事は[Aizu Advent Calendar 2023](https://adventar.org/calendars/9379)の12日目の記事です。
また、誕生日でした。(遅れてごめんなさい)

# 本題

FTPサーバをPythonの標準ライブラリにある`socket`で実装する。

# 前提
基本的にHTTPやFTPなどの通信はTCP・UDP通信を使用した文字列のフォーマットを指す。  
例えば、以下のHTTPリクエストの文字列をTCP通信でサーバへ送ることでHTTPリクエストができる。
```
POST /post HTTP/1.1
Host: localhost
Connection: keep-alive
Content-Length: 47
Content-Type: application/json
Accept: */*

{
  "user":"suzaku",
  "content":"HTTP通信"
}
```

# 参考文献について
RFC959を日本語訳した記事を参考に実装を行っています。
http://srgia.com/docs/rfc959j.html

# FTP通信の基本について
FTP通信には**制御用コネクション**と**データ転送用コネクション**の二つのコネクションがあります。
**制御用コネクション**の通信の基本はクライアントサーバモデルの通信方式となっています。
**データ転送用コネクション**では、FTPクライアント側から送られてくるIPアドレスとポートにFTPサーバ側から接続をして、データのやり取りをします。

# 制御用コネクションの通信
## クライアントからの通信
FTP通信ではFTPクライアントから**コマンドコード**(4文字以下のアルファベット)と**引数**(ない場合もある)が空白区切りでコマンドとして送られてくる。
例えば、カレントディレクトリを把握するコマンドは`PWD`で、カレントディレクトリを`/hoge/fuga`に変更するコマンドは`CWD /hoge/fuga`です。

## サーバからの通信
サーバはクライアントからのコマンドに応じて、**リプライコード**(3桁の数字)と**任意の文字列**をリプライとして送ります。
一部のコマンドでは、データのやり取りの開始と終了を知らせるなどのために**リプライが2回送られる**ことがあります。
また、コマンドごとに送り返すリプライコードが決められており、CWDの場合は下にリプライコードから選ぶことになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/467463/b1b7908a-2d3d-ec42-475b-0e39f734d998.png)

# データ転送用コネクション

データの通信では、**クライアント側にTCPサーバが建てられ、サーバ側がそのTCPサーバに接続する**ことでファイルのデータを送受信する。

## データ転送用コネクションを繋げて閉じるまで
データ転送を要する`LIST`や`RETR`や`STOR`などのコマンドは、その前に`PORT`コマンドが送られてくる。
実際の通信は以下の図のようになる。
```mermaid
sequenceDiagram
    クライアント ->> サーバ: PORTコマンド
    サーバ ->> クライアント: リプライコード: PORTが正常に送られてきたよ
    クライアント ->> サーバ : LISTやRETRなどのコマンド
    サーバ ->> クライアント: リプライコード: データ通信開始
    クライアント -> サーバ : データ通信(ここは別のコネクション)
    サーバ ->> クライアント: リプライコード: データ通信終了
```

## PORTの指定
`PORT`コマンドでは、データを転送するクライアントのTCPサーバのIPアドレスとポート番号が送られてくる。
```
PORT 192,168,0,5,132,153
```

上の例の場合、1番目から4番目がIPアドレス`192.168.0.5`で、5番目と6番目はPORTの上位8ビットと下位8ビットを表しており計算は`(132<<8)|153`で33945ポートを表しています。

# プログラム

プログラムを以下に示します。

```python3
import socket
import os
import sys

server_ip = "127.0.0.1"
server_port = 20000
listen_num = 1
buffer_size = 1024

if len(sys.argv) >= 2:
    server_port = int(sys.argv[1])

tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_server.bind((server_ip, server_port))
tcp_server.listen(listen_num)

user = "hoge"
password = "pass"

current_directory = "/"

while True:
    client, address = tcp_server.accept()
    client.settimeout(60.0*10)

    print("Connected {address}")
    client.send(b"220 Welcome to FTP Server\n")

    while True:
        command = [d.decode("utf-8")
                   for d in client.recv(buffer_size).split()]
        print(command)
        command_code = command[0]
        args = command[1:]
        if command_code == "USER":
            if args[0] == user:
                client.send(b'331 Need Password\n')
            else:
                client.send(b'530 Login Faild\n')
                client.close()
                break

        elif command_code == "PASS":
            if args[0] == password:
                client.send(b'230 Login\n')
            else:
                client.send(b'530 Login Faild\n')
                client.close()
                break
        
        elif command_code == "SYST":
            client.send(b'215 Python3\n')   

        elif command_code == "PORT":
            address = args[0].split(',')
            host = '.'.join(address[:4])
            port = (int(address[4]) << 8) | int(address[5])

            client.send(b'200\n')

        elif command_code == "LIST":
            client.send(b'150 Opening\n')
            
            files = os.listdir("."+current_directory)
            files_text = ' '+' '.join(files)

            data_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            data_client.connect((host, port))
            data_client.send(files_text.encode('utf-8')+b'\r\n')
            data_client.close()

            client.send(b'226 Closing\n')

        elif command_code == "RETR":
            filepath = args[0]
            if os.path.isabs(filepath):
                filepath = "."+filepath
            else:
                filepath = os.path.join("."+current_directory, filepath)

            if not os.path.isfile(filepath):
                client.send(b'550 File not found\n')
                continue

            with open(filepath) as f:
                filedata = f.read()

            client.send(b'150 Opening\n')
            
            data_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            data_client.connect((host, port))
            data_client.send(filedata.encode('utf-8'))
            data_client.close()
            
            client.send(b'226 Closing\n')

        elif command_code == "STOR":
            client.send(b'150 Opening\n')

            data_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            data_client.connect((host, port))
            filedata = b""
            while True:
                data = data_client.recv(buffer_size)
                if data == b'':
                    break
                filedata += data
            data_client.close()

            filepath = args[0]
            if os.path.isabs(filepath):
                filepath = "."+filepath
            else:
                filepath = os.path.join("."+current_directory, filepath)
            
            with open(filepath, 'w') as f:
                f.write(filedata.decode('utf-8'))

            client.send(b'226 Closing\n')

        elif command_code == "CWD":
            new_path = args[0]
            if not os.path.isabs(new_path):
                new_path = os.path.join(current_directory, new_path)
            new_path = os.path.normpath(new_path)

            if os.path.isdir("."+new_path):
                current_directory = new_path
                client.send(b'250 Change Current Directory\n')
            else:
                client.send(b'501 This path is not found\n')

        elif command_code == "PWD":
            client.send(f'257 "{current_directory}"\n'.encode('utf-8'))
        
        elif command_code == "QUIT":
            client.send(b'221 ByeBye\n')
            client.close()
            break

        else:
            client.send(input().encode('utf-8')+b"\n")
```

このプログラムから、**制御用コネクション**と**データ転送用コネクション**の通信部分について解説をしていきます。(TCP通信の詳しいところは省くよ)

## 接続開始

ユーザ側が接続してきたらタイムアウトの設定をして、220を返します。

```python3
client, address = tcp_server.accept()
client.settimeout(60.0*10)

print("Connected {address}")
client.send(b"220 Welcome to FTP Server\n")
```

## クライアント側からのコマンド受け取り
コマンドをsplitで分割&文字列に変換し、最初のコマンドコードとそれ以降の引数で変数を分けました。

```python3
command = [d.decode("utf-8")
           for d in client.recv(buffer_size).split()]
print(command)
command_code = command[0]
args = command[1:]
```

## ユーザ認証

1. 接続が開始されるとクライアント側から`USER`コマンドが引数にユーザ名を持って送られてくる。
ユーザ名が合っている場合は**パスワードを要求する331を返答**する。合っていない場合は530を返答してコネクションを切断をする。
1. 331の後、クライアント側から`PASS`コマンドが引数にパスワードを持って送られてくる。
ユーザ名が合っている場合は**ログインが完了した230を返答**する。合っていない場合は530を返答してコネクションを切断をする。
1. 230の後、クライアント側からFTPサーバのシステム名を聞いてくる`SYST`コマンド送られてくる。
返答として、215とシステム名(今回の場合は(ふざけて)Python3にする)を指定する。

```python3
if command_code == "USER":
    if args[0] == user:
        client.send(b'331 Need Password\n')
    else:
        client.send(b'530 Login Faild\n')
        client.close()
        break

elif command_code == "PASS":
    if args[0] == password:
        client.send(b'230 Login\n')
    else:
        client.send(b'530 Login Faild\n')
        client.close()
        break

elif command_code == "SYST":
    client.send(b'215 Python3\n')   
```

## PORTの指定

先ほど説明したPORTコマンドの処理をします。
引数を`,`で分割し、前の4つを'.'で再結合してホスト、後の2つを計算してポートを記憶します。

```python3
elif command_code == "PORT":
    address = args[0].split(',')
    host = '.'.join(address[:4])
    port = (int(address[4]) << 8) | int(address[5])

    client.send(b'200\n')
```

## ファイルやディレクトリの一覧を表示への対応(`ls`)

1. **150を返答してデータ通信の開始**を知らせます。
1. 前述の`PORT`コマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。
1. ファイルやディレクトリの一覧を送信します。この時、最後に末尾にOSなどに適した改行コードを付けます。(今回は`\r\n`)
1. データ転送用コネクションを閉じた後に、**閉じたことを知らせる226**をクライアントに送ります。

```python3
elif command_code == "LIST":
    client.send(b'150 Opening\n')

    data_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    data_client.connect((host, port))

    ファイルやディレクトリの一覧を取得
    data_client.send(files.encode('utf-8')+b'\r\n')
    
    data_client.close()

    client.send(b'226 Closing\n')
```

## ファイルデータの送信(`get`)

1. 引数からファイルパスを取得し、絶対パスや相対パスなどの処理を行う。
1. ファイルが存在していなければエラーとして550を返答する。
ファイルが存在しているならば**150を返答してデータ通信の開始**を知らせます。
1. 前述の`PORT`コマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。
1. ファイルデータを送信します。
1. データ転送用コネクションを閉じた後に、**閉じたことを知らせる226**をクライアントに送ります。

```python3
elif command_code == "RETR":
    filepath = args[0]
    filepathの処理

    if not os.path.isfile(filepath):
        client.send(b'550 File not found\n')
        continue

    client.send(b'150 Opening\n')
    
    data_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    data_client.connect((host, port))
    data_client.send(filedata.encode('utf-8'))
    data_client.close()
    
    client.send(b'226 Closing\n')
```

## ファイルデータの受信(`put`)

1. **150を返答してデータ通信の開始**を知らせます。
1. 前述の`PORT`コマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。
1. 接続するとファイルデータが送られてくるので、空文字列が送られてくるまでファイルデータを受信します。
1. 引数から取得したファイルパスにファイルを生成する。
1. データ転送用コネクションを閉じた後に、**閉じたことを知らせる226**をクライアントに送ります。

```python3
elif command_code == "STOR":
    client.send(b'150 Opening\n')

    data_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    data_client.connect((host, port))
    filedata = b""
    while True:
        data = data_client.recv(buffer_size)
        if data == b'':
            break
        filedata += data
    data_client.close()

    filepath = args[0]
    ファイルパスの処理
    
    with open(filepath, 'w') as f:
        f.write(filedata.decode('utf-8'))

    client.send(b'226 Closing\n')
```

## カレントディレクトリ変更への対応(`cd`)

引数からディレクトリのパスを確認し、ディレクトリが存在するならばカレントディレクトリを変更して250を返答する。ディレクトリが存在しなければ、501を返答する。

```python3
elif command_code == "CWD":
    new_path = args[0]
    new_pathの処理

    if ディレクトリが存在するか:
        current_directory = new_path
        client.send(b'250 Change Current Directory\n')
    else:
        client.send(b'501 This path is not found\n')
```

## カレントディレクトリの確認(`pwd`)

PWDが来たら、257と引数にカレントディレクトリの文字列を返答するだけです。

```python3
elif command_code == "PWD":
    client.send(f'257 "{current_directory}"\n'.encode('utf-8'))
```

## FTP接続の終了

接続終了時はQUITが送られてくるので、221と返して接続を閉じます。

```python3
elif command_code == "QUIT":
    client.send(b'221 ByeBye\n')
    client.close()
    break
```

# 感想

FTPサーバですが、TCPで意外と簡単に実装することが出来ました。
しかし、ただのFTPですのでユーザ名やパスワード、ファイルデータが平文で送受信されているので、FTPSの実装をしていきたいと思います。

加えて、今後はESP32上などに実装して面白いことができれば良いなと考えています。

# 参考文献

https://ja.wikipedia.org/wiki/File_Transfer_Protocol
http://srgia.com/docs/rfc959j.html