LoginSignup
2
2

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

Posted at

はじめに

この記事はAizu Advent Calendar 2023の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

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

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

データ転送用コネクションを繋げて閉じるまで

データ転送を要するLISTRETRSTORなどのコマンドは、その前にPORTコマンドが送られてくる。
実際の通信は以下の図のようになる。

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ポートを表しています。

プログラム

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

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を返します。

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

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

クライアント側からのコマンド受け取り

コマンドをsplitで分割&文字列に変換し、最初のコマンドコードとそれ以降の引数で変数を分けました。

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を返答してコネクションを切断をする。
  2. 331の後、クライアント側からPASSコマンドが引数にパスワードを持って送られてくる。
    ユーザ名が合っている場合はログインが完了した230を返答する。合っていない場合は530を返答してコネクションを切断をする。
  3. 230の後、クライアント側からFTPサーバのシステム名を聞いてくるSYSTコマンド送られてくる。
    返答として、215とシステム名(今回の場合は(ふざけて)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つを計算してポートを記憶します。

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を返答してデータ通信の開始を知らせます。
  2. 前述のPORTコマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。
  3. ファイルやディレクトリの一覧を送信します。この時、最後に末尾にOSなどに適した改行コードを付けます。(今回は\r\n)
  4. データ転送用コネクションを閉じた後に、閉じたことを知らせる226をクライアントに送ります。
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. 引数からファイルパスを取得し、絶対パスや相対パスなどの処理を行う。
  2. ファイルが存在していなければエラーとして550を返答する。
    ファイルが存在しているならば150を返答してデータ通信の開始を知らせます。
  3. 前述のPORTコマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。
  4. ファイルデータを送信します。
  5. データ転送用コネクションを閉じた後に、閉じたことを知らせる226をクライアントに送ります。
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を返答してデータ通信の開始を知らせます。
  2. 前述のPORTコマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。
  3. 接続するとファイルデータが送られてくるので、空文字列が送られてくるまでファイルデータを受信します。
  4. 引数から取得したファイルパスにファイルを生成する。
  5. データ転送用コネクションを閉じた後に、閉じたことを知らせる226をクライアントに送ります。
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を返答する。

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と引数にカレントディレクトリの文字列を返答するだけです。

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

FTP接続の終了

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

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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2