Python을 이용한 간단한 FTP 클라이언트-서버 시스템 구현

이 문서에서는 Python의 모듈을 활용하여 기본적인 FTP 클라이언트와 서버를 구현하는 방법을 살펴봅니다. 주요 내용은 원격 명령 실행, 파일 전송, 인증 처리 등을 포함합니다. 예제 코드는 교육 목적으로 간소화되었으며, 실제 환경에서는 보안 및 오류 처리를 강화해야 합니다.

서버 측 구현: socketserver 기반

서버는 socketserver.TCPServer를 사용하여 다중 클라이언트 요청을 처리합니다. 각 클라이언트 연결에 대해 BaseRequestHandler를 상속받은 핸들러가 동작합니다.

import socketserver
import json
import os
import hashlib
import configparser
from conf import settings

STATUS_CODE = {
    250: "Invalid cmd format",
    251: "Invalid cmd",
    252: "Invalid auth data",
    253: "Wrong username or password",
    254: "Passed authentication",
    255: "Filename not provided",
    256: "File not found",
    257: "Ready to send file",
    258: "MD5 verification",
    200: "OK"
}

class MyHandler(socketserver.BaseRequestHandler):
    def handle(self):
        while True:
            raw_data = self.request.recv(1024).strip()
            if not raw_data:
                break
            data = json.loads(raw_data.decode())
            action = data.get("action", "")
            handler = getattr(self, f"_{action}", None)
            if handler:
                handler(data)
            else:
                self.send_response(250)

    def send_response(self, code, extra=None):
        resp = {"status_code": code, "message": STATUS_CODE[code]}
        if extra:
            resp.update(extra)
        self.request.send(json.dumps(resp).encode())

    def _auth(self, data):
        user = self.authenticate(data.get("username"), data.get("password"))
        if user:
            self.user = user
            self.send_response(254)
        else:
            self.send_response(253)

    def authenticate(self, username, password):
        cfg = configparser.ConfigParser()
        cfg.read(settings.ACCOUNT_FILE)
        if username in cfg.sections() and cfg[username]["Password"] == password:
            cfg[username]["User"] = username
            return cfg[username]
        return None

    def _get(self, data):
        filename = data.get("filename")
        if not filename:
            self.send_response(255)
            return
        abs_path = os.path.join(settings.USER_HOME, self.user["User"], filename)
        if os.path.isfile(abs_path):
            fsize = os.path.getsize(abs_path)
            self.send_response(257, {"file_size": fsize})
            self.request.recv(1)  # 클라이언트 확인 대기
            with open(abs_path, "rb") as f:
                md5 = hashlib.md5()
                for chunk in iter(lambda: f.read(4096), b""):
                    self.request.send(chunk)
                    md5.update(chunk)
            self.request.send(md5.hexdigest().encode())
        else:
            self.send_response(256)

if __name__ == "__main__":
    server = socketserver.TCPServer(("0.0.0.0", 2121), MyHandler)
    server.serve_forever()

클라이언트 측 구현

클라이언트는 명령줄 인자를 받아 서버에 연결하고, 사용자 인증 후 파일 다운로드 등의 작업을 수행합니다.

import socket
import json
import hashlib
import optparse

class FTPClient:
    def __init__(self):
        parser = optparse.OptionParser()
        parser.add_option("-s", "--server", dest="server", default="localhost")
        parser.add_option("-P", "--port", dest="port", type="int", default=2121)
        parser.add_option("-u", "--username", dest="username")
        parser.add_option("-p", "--password", dest="password")
        self.options, _ = parser.parse_args()
        self.sock = socket.socket()
        self.sock.connect((self.options.server, self.options.port))

    def auth(self):
        user = self.options.username or input("Username: ")
        pwd = self.options.password or input("Password: ")
        payload = {"action": "auth", "username": user, "password": pwd}
        self.sock.send(json.dumps(payload).encode())
        resp = json.loads(self.sock.recv(1024).decode())
        if resp.get("status_code") == 254:
            self.user = user
            print("Authentication success")
            return True
        print(resp.get("message"))
        return False

    def download(self, filename):
        payload = {"action": "get", "filename": filename}
        self.sock.send(json.dumps(payload).encode())
        resp = json.loads(self.sock.recv(1024).decode())
        if resp.get("status_code") != 257:
            print(resp.get("message"))
            return
        fsize = resp["file_size"]
        self.sock.send(b"1")
        received = 0
        md5 = hashlib.md5()
        with open(filename, "wb") as f:
            while received < fsize:
                chunk = self.sock.recv(4096)
                f.write(chunk)
                md5.update(chunk)
                received += len(chunk)
        remote_md5 = self.sock.recv(32).decode()
        if md5.hexdigest() == remote_md5:
            print("File integrity verified")
        else:
            print("MD5 mismatch")

    def interactive(self):
        if not self.auth():
            return
        while True:
            cmd = input(f"[{self.user}]> ").strip()
            if not cmd:
                continue
            parts = cmd.split()
            if parts[0] == "get" and len(parts) == 2:
                self.download(parts[1])
            elif parts[0] == "quit":
                break
            else:
                print("Available commands: get <file>, quit")

if __name__ == "__main__":
    client = FTPClient()
    client.interactive()

핵심 포인트

  • 프로토콜 설계: JSON 기반의 요청/응답 구조를 사용하여 명령과 상태 코드를 교환합니다. 각 명령은 "action" 키로 구분됩니다.
  • 파일 전송: get 명령은 파일 크기를 먼저 전송하고, 이후 바이너리 데이터를 스트리밍합니다. MD5 해시를 통해 무결성을 검증합니다.
  • 사용자 인증: 설정 파일(ini 형식)에 저장된 사용자 정보를 확인합니다. 실제 배포 시에는 비밀번호 해싱이 필요합니다.

확장 가능성

  • 업로드 기능: put 명령을 추가하여 클라이언트의 파일을 서버로 전송할 수 있습니다.
  • 디렉터리 목록: ls 명령으로 서버의 파일 목록을 조회하는 기능을 구현할 수 있습니다.
  • 멀티스레드 처리: socketserver.ThreadingTCPServer를 사용하면 동시 접속을 처리할 수 있습니다.

태그: python FTP socketserver paramiko JSON

7월 25일 11:01에 게시됨