はじめに
この記事は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の場合は下にリプライコードから選ぶことになります。
データ転送用コネクション
データの通信では、クライアント側にTCPサーバが建てられ、サーバ側がそのTCPサーバに接続することでファイルのデータを送受信する。
データ転送用コネクションを繋げて閉じるまで
データ転送を要するLIST
やRETR
やSTOR
などのコマンドは、その前に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:]
ユーザ認証
- 接続が開始されるとクライアント側から
USER
コマンドが引数にユーザ名を持って送られてくる。
ユーザ名が合っている場合はパスワードを要求する331を返答する。合っていない場合は530を返答してコネクションを切断をする。 - 331の後、クライアント側から
PASS
コマンドが引数にパスワードを持って送られてくる。
ユーザ名が合っている場合はログインが完了した230を返答する。合っていない場合は530を返答してコネクションを切断をする。 - 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
)
- 150を返答してデータ通信の開始を知らせます。
- 前述の
PORT
コマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。 - ファイルやディレクトリの一覧を送信します。この時、最後に末尾にOSなどに適した改行コードを付けます。(今回は
\r\n
) - データ転送用コネクションを閉じた後に、閉じたことを知らせる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
)
- 引数からファイルパスを取得し、絶対パスや相対パスなどの処理を行う。
- ファイルが存在していなければエラーとして550を返答する。
ファイルが存在しているならば150を返答してデータ通信の開始を知らせます。 - 前述の
PORT
コマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。 - ファイルデータを送信します。
- データ転送用コネクションを閉じた後に、閉じたことを知らせる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
)
- 150を返答してデータ通信の開始を知らせます。
- 前述の
PORT
コマンドで指定されたホストとポートにデータ転送用コネクションとして接続します。 - 接続するとファイルデータが送られてくるので、空文字列が送られてくるまでファイルデータを受信します。
- 引数から取得したファイルパスにファイルを生成する。
- データ転送用コネクションを閉じた後に、閉じたことを知らせる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