FTP-протокол + WinSocks на примере простого FTP-клиента (зеркала) — Архив WASM.RU
Введение.
В этой статье я не ставлю себе целью пересказать все RFC касающиеся протокола FTP, коих не мало, в них вы сможете найти информацию гораздо полнее, попытаюсь лишь в общих чертах познакомить Вас с протоколом FTP и основными приемами работы с ним со стороны клиента.
Общие сведения о протоколе FTP.
Итак, FTP (File Transfer Protocol) – протокол передачи файлов в сетях стандарта TCP/IP. Этот протокол был специально создан для облегчения и стандартизации программирования алгоритмов передачи файлов между клиентом и сервером. Как и все протоколы высокого уровня, он не занимается непосредственной передачей данных (этим занимается протокол более низкого уровня – TCP, а так же протоколы ниже), а лишь описывает способ «общения» клиент-сервер.
Перейдем непосредственно к описанию протокола. Отличительной его особенностью является использование двух соединений между сервером и клиентом. Одно соединение (командное или управляющее) используется для передачи команд серверу, а так же приема ответов на эти команды. Второе соединение (соединение данных) используется непосредственно для приема или передачи данных. Управляющее соединение всегда происходит со стороны клиента на порт сервера 21 и остается на протяжении всего сеанса работы открытым. Соединение данных открывается и закрывается по мере необходимости в приеме или получении данных.
После того как установлено управляющее соединение клиент может отправлять по нему серверу различные команды. Каждая команда представляет из себя 3 или 4 заглавных символа ASCII, за которыми после одного или более пробелов следуют, в некоторых командах не обязательные аргументы. Любая команда заканчивается парой CR, LF – это, несомненно, известные всем 0dh, 0ah – если речь идет о DOS/Windows. В общих чертах схема команды такая:
Команда [аргумент(ы)] CR, LF.
Всего существует чуть более 30 команд (в RFC959 – 33) которые могут быть посланы серверу, но это совсем не значит что сервер все их будет поддерживать. Приведу пример наиболее часто используемых команд.
USER имя пользователя
Указывает имя пользователя
PASS пароль
Указывает пароль пользователя
LIST список файлов
Запрос списка файлов
PORT n1,n2,n3,n4,n5,n6
Указание IP и порта для соединения данных
RETR имя файла
Получить файл с сервера
STOR имя файла
Положить файл на сервер
TYPE тип
Тип передаваемых данных
QUIT
Отключение от сервера
ABOR
Отмена предыдущее команды. Прекращение передачи данных.
При получении запроса сервер, по тому же управляющему соединению отправляет ответ на него. Ответ сервера состоит из трех символов (цифр) в формате ASCII, за которыми следует не обязательный текст, обычно поясняющий цифирный код ответа, за этим пояснением следуют неизменные CR, LF. Ответ например может быть таким: 226 File send OK. – в этом примере сервер сообщает нам о том, что файл отправлен с его стороны (что совсем не означает, что он уже получен со стороны клиента). Первая цифра отклика сервера наиболее значимая, и дает однозначное представление о том как выполнилась (или не выполнилась) команда. Значения могут быть такими:
1хх
Команда находится в процессе выполнения, необходимо дождаться еще одного сообщения перед тем, как давать следующую команду.
2хх
Команда выполнена. Сервер находится в ожидании следующей.
3хх
Команда выполнена, но для продолжения необходима еще одна команда
4хх
Команда не была выполнена, необходимо подождать и повторить команду
5хх
Команда не была выполнена и не будет выполнена при повторе.
По второй цифре отклика можно судить о том, какая ситуация привела к возникновению отклика:
x0x
Ошибка синтаксиса.
x1x
Информация.
x2x
Отклик относится к состоянию управляющего или соединению данных.
x3x
Отклик относится к аутентификации пользователя или состоянию бюджета.
x4x
Не определенно.
x5x
Отклик относится к состоянию файловой системы.
Ну и наконец третья цифра отклика несет в себе дополнительную информацию.
Следует обратить особое внимание на то, что хотя на большую часть команд сервер отвечает одним откликом, есть и широко используются команды, в ответ на которые сервер генерирует несколько откликов. При этом первая цифра первого отклика будет «1» - т.е. если взглянуть на таблицы выше, сервер сообщает нам о том, что необходимо подождать еще одного сообщения от него, перед тем, как посылать следующую команду. Примером такой команды может служить команда RETR, когда сервер принимает ее и начинает пересылку данных он отвечает нам что-то вроде: «150 Opening BINARY mode data connection for HIDE.ASM (958 bytes).» - смысл сообщения сводится к «начата передача данных». Затем, когда данные им уже будут отправлены (но опять хочу заострить внимание – не факт, что получены клиентом) он отправит по управляющему соединению еще один отклик – «226 File send OK.» - т.е. «файл отправлен». Вот в этом случае только после получения второго сообщения сервер готов к выполнению следующей команды. Вместо последнего сообщения мы вполне можем получить сообщение с ошибкой начинающееся с «4» - в том случае, если возникнут какие-либо проблемы с передачей файла.
В общих чертах это все, что касается управляющего соединения.
Теперь поговорим о соединении данных. Как уже говорилось выше, соединение данных организуется по мере необходимости, и закрывается каждый раз после передачи или приема данных. Так происходит потому, что режим передачи данных между клиентом и сервером – потоковый, а в таком режиме окончание передачи данных – закрытие соединения. Из вышесказанного мы должны сделать один немаловажный вывод – судить об окончании передачи данных со стороны сервера мы можем по закрытию соединения.
Обычно соединение данных открывается следующим образом:
- клиент выбирает свободный порт на своем хосте и осуществляет пассивное открытие на него;
- клиент сообщает серверу по управляющему соединению свой IP-адрес и номер порта, на который сделал пассивное открытие;
- сервер, получив порт и IP-адрес осуществляет его активное открытие;
- передаются или принимаются данные;
- в зависимости от того кто передает, а кто принимает данные осуществляется закрытие порта.
Небольшое отступление: если вы внимательно прочтете второй пункт, может возникнуть вопрос – «А что будет если мы передадим серверу фиктивный адрес и порт?». Ответ неоднозначен, сервер может проверять IP-адрес, но это происходит не всегда, поэтому существуют некоторые интересные «заморочки» с использованием фиктивных адресов.
Что касается порта, выбираемого для соединения данных клиентом. Обычно используется динамически назначаемый ОС порт, - т.е. делается запрос к системе, она дает первый свободный. Если клиент не указывает серверу порт для соединения, оно происходит на порт с которого было проведено управляющее соединение (поступать так не рекомендуется). Сервер всегда осуществляет соединение данных с 20-го порта.
Это все основное, что я хотел рассказать о соединении данных.
Теперь, когда мы знаем для чего и как работают оба соединения, хочу отметить еще один момент (при первом прочтении можно пропустить). Команда LIST возвращает список файлов текущей директории, и возвращает его по соединению данных. Список представляет из себя набор строк ASCII оканчивающихся символами CR, LF. Каждая строка несет в себе информацию об одном из элементов запрашиваемого каталога. Общий шаблон этой строки такой:
Txxxxxxxxx[ ]uk[ ]user[ ]group[ ]size[ ]mm[ ]dd[ ]yytt[ ]name CR, LF
где,
T – тип элемента («d» - каталог, «-» - файл, «l» - ссылка и т.д.);
xxxxxxxxx – атрибуты защиты файла;
user – пользователь, владелец файла;
group – группа владельца;
size - размер элемента;
mm – месяц создания элемента в текстовом виде, например «jul»;
dd – день месяца создания элемента;
yytt – здесь может быть год или время создания элемента;
name – имя элемента (файла, каталога, ссылки);
[ ] – один или более пробелов.Да, между этими элементами может быть различное количество пробелов, надо сказать спасибо, что в различных реализациях серверов оставили одно количество значимых столбцов, поэтому при анализе таблицы файлов следует это учитывать. Стоит еще учесть такую вещь, что не всегда первая строка из таблицы есть значимая строка, несущая информацию о первом элементе каталога. В некоторых реализациях FTP-серверов (например ftpd на FreeBSD), первой строкой списка является строка «total NN».
Как это должно работать?
Давайте немного отвлечемся и посмотрим, как же должен выглядеть FTP сеанс получения файла «изнутри». Итак, мы запускаем клиента. Сервер в это время уже пассивно открыл и слушает 21-ый порт. В первую очередь нам необходимо создать управляющее соединение – конектимся на сервер на порт 21. Что дальше? Сразу, как только мы удачно законектились с сервером по созданному управляющему соединению нам приходит приветствие от сервера, это будет что-то вроде «220 VSFTP deamon base on Alt Linux 2.2, Shpakovsky».
Следующим шагом должна быть регистрация – допустим мы соединяемся с анонимным сервером - по управляющему соединению клиент посылает серверу команду USER anonymous, на что, если сервер поддерживает анонимного пользователя получаем ответ: «331 Please specify the password.» - «пожалуйста сообщите пароль», заметим цифру «3» в ответе сервера, что означает, что для продолжения требуется еще команда, что собственно и делает клиент – посылаем команду PASS 1@1 – в качестве пароля указав фиктивный e-mail. На что получаем ответ сервера «230 Login successful. Have fun.» - «Регистрация прошла успешно».
Все, теперь наши действия зависят от того что мы хотим, а как говорилось выше, хотим мы получить с сервера файл, пусть к примеру это будет файл «HIDE.EXE», расположенный в корневом каталоге сервера. Перед тем, как осуществлять прием или передачу данных серверу необходимо указать какой тип данных будет передаваться, делается это командой TYPE N, где N = «A», если тип ASCII и N = «I», если файл бинарный. Клиент посылает серверу команду TYPE I, на что получает ответ – «200 Switching to Binary mode.».
Итак, осталось только получить файл. Для этого клиенту необходимо открыть соединение данных. Клиентом выбирается свободный порт, осуществляется пассивное открытие, т.е. клиент его «слушает». Дальше клиенту нужно сообщить серверу свой IP-адрес и номер порта, который только что пассивно открыл (допусти IP-адрес хоста клиента будет 10.21.23.10, а номер порта 2000). Клиент посылает серверу по управляющему соединению команду PORT 10,21,23,10,7,208 – «что за 7,208?» - спросите вы. Это и есть номер порта строится он так – 7*256+208 = 2000. Сервер после получения этой команды попытается сделать активное открытие указанного порта и в случае удачи вернет что-то вроде «200 PORT command successful. Consider using PASV.».
Все, соединение данных установлено остается дать команду передачи данных серверу, что и делает клиент - RETR HIDE.EXE, на что в случае если все нормально (файл существует и может быть передан) сервер отвечает «150 Opening BINARY mode data connection for HIDE.EXE (4096 bytes).» и начинает сливать файл по соединению данных. Опять обращаю ваше внимание на первую цифру ответа. Когда файл будет полностью отправлен сервер пошлет сообщение «226 File send OK.» и произведет закрытие соединения данных.
Клиент ждет окончания получения данных со своей стороны (о чем свидетельствует получение сообщения от сервера + закрытие соединения данных, тут есть ньюансы, но о них позже) после чего закрывает порт соединения данных со своей стороны.
Итак файл получен клиентом, остается разорвать управляющее соединение, клиент посылает команду QUIT, сервер отвечает «221 Goodbye.» и разрывает соединение.
Вот собственно самое важные теоретические сведения о протоколе. Перед тем как переходить к практике очень советую побаловаться управляющим соединением с FTP-сервером, используя telnet, соединение данных создать не получится, но команды и ответы на них будут на виду. Так же рекомендую поработать с каким-либо консольным клиентом FTP и понаблюдать во время всего этого за созданием и закрытием соединений с помощью какой-нибудь утилиты для этого, коих в Интернете как грязи.
Реализация.
Теперь о самой реализации. В этой реализации клиента я использую non-blocking (не блокирующие) сокеты, поэтому модель клиента – событийная, т.е. выполнять те или иные действия, касающиеся используемых клиентом сокетов клиент будет только при возникновении соответствующего события (например закрытие соединения, уведомление о получении данных и т.д.). В качестве событий используются сообщения, приходящие в процедуру главного окна. Кроме того, модель программы поточная, используется поток для чтения соединения данных и поток для чтения управляющего соединения, а так же основной поток клиента, запускающийся при нажатии на кнопку «соединение». Так как программа многопоточная для синхронизации работы этих трех потоков (а так же процедуры сообщений главного окна) используются «event’s» («события», не путать эти события, используемые программой как датчик 1 или 0 – произошло или не произошло событие, и события касающиеся сокетов, которые приходят на процедуру главного окна).
Итак, начнем. При создании основного окна приложения мы проводим основную инициализацию программы, поясню основные моменты:
Код (Text):
call VirtualAlloc,ebx,1024000,MEM_COMMIT+MEM_RESERVE,PAGE_READWRITE mov ReciveDataBufferOffset,eax call VirtualAlloc,ebx,10240,MEM_COMMIT+MEM_RESERVE,PAGE_READWRITE mov ReciveCommandBufferOffset,eaxЗдесь выделяется память под буфер приема файла (1 Мб) и под буфер команд (10 Кб).
Код (Text):
call CreateEventA,ebx,ebx,ebx,ebx mov HDataReciveEvent,eax ……Создаются объекты event (события) более подробно о назначении событий позже.
Код (Text):
call CreateThread,ebx,ebx,offset ReciveThread,offset ReciveDataThreadStruc, \ NORMAL_PRIORITY_CLASS,offset ThreadID_data call CreateThread,ebx,ebx,offset ReciveThread,offset ReciveCommandThreadStruc,\ NORMAL_PRIORITY_CLASS,offset ThreadID_commandСоздаются 2 потока – один для чтения данных, другой для чтения управляющего потока. Оба этих потока при старте находятся в приостановленном состоянии, и начинают работать только при установлении соответствующего события.
Код (Text):
call gethostname, offset HostName,64 call gethostbyname,offset HostName ….. mov PortInPort,esi ret 0Смысл строк выше в получении IP-адреса нашего хоста, небольшом преобразовании и записи его в отдельное место, адрес хоста нам потребуется для выполнения команды PORT.
На этом процесс начальной инициализации заканчивается, и программа находится в состоянии ожидания команды пользователя. Давайте посмотрим что происходит при нажатии пользователем кнопки «соединиться».
В основной процедуре окна создается главный поток приложения, рассмотрим его ключевые моменты.
Сразу при старте мы инициализируем переменные относящиеся к приему данных и получаем из окна диалога введенные пользователям параметры соединения (сервер, пароль и.д.). После этого нам необходимо создать управляющее соединение с сервером, что мы и делаем:
Код (Text):
- создаем сокет; call socket, AF_INET, SOCK_STREAM, IPPROTO_TCP mov ReciveCommandSock,eax - выбираем неблокирующий режим для сокета, указываем что хотим получать сообщения о получении новых данных на сокет, а так же о успешном его приконекчивании. call WSAAsyncSelect, ReciveCommandSock, newhwnd, WM_COMMANDSOCK,FD_READ+FD_CONNECT - получаем информацию об удаленном хосте и конектимся к нему ….. call connect,ReciveCommandSock,offset sockaddr_in,16 - ждем получения события FD_CONNECT, когда оно приходит в процедуру главного окна обработчик с помощью call SetEvent,HWaitConnectEvent устанавливает событие, чего мы и ожидаем в следующей строке, если событие не будет установлено в течении 5 секунд, выводим сообщение об ошибке и заканчиваем сеанс связи. call WaitForSingleObject,HWaitConnectEvent,5000 call ResetEvent,HWaitConnectEvent - так как после соединения сервер должен послать нам приветствие, ждем его еще 5 секунд, если оно не поступило - выходим. Процедура WaitAnswerRecive описана ниже. call WaitAnswerRecive,5000 or eax,eax jnz errorwithregisration - входным параметром к функции является интервал, в течении которого, функция будет ждать ответа сервера, если за указанный интервал ответ не будет получен, функция выводит сообщение об ошибке и завершается с ненулевым значением регистра eax. WaitAnswerRecive proc TimeToWait:dword call WaitForSingleObject,HWaitCommandEvent,TimeToWait - ожидаем возникновение события HWaitCommandEvent, которое устанавливается в потоке получения данных по управляющему соединению, в случае успешного получения данных. or eax,eax jz NoTimeOutGet call MessageBoxA,newhwnd,offset ErrTimeOutCommand,offset ErrorCap,40h call ResetEvent,HWaitCommandEvent - сбросили событие HWaitCommandEvent т.к. истек таймаут, и событие осталось в сигнальном состоянии. NoTimeOutGet: ret WaitAnswerRecive endpТеперь пришло время рассмотреть потоки получения данных, как говорилось выше эти потоки создаются в процессе инициализации главного окна, и находятся постоянно в процессе ожидания новых данных, потоки активизируются в процедуре главного окна при получении ей сообщении о том, что есть новые данные, сообщение для управляющего соединения мы определили в самом начале главного потока функцией WSAAsyncSelect, сообщение для соединения данных определяется при создании этого соединения, как мы увидим позже.
Универсальный трэд для получения данных по управляющему и соединению данных приведен ниже.
Код (Text):
- в качестве параметра поток получает адрес структуры ReciveDataThreadStruc или ReciveCommandThreadStruc в зависимости от предназначения трэда. Структура для ReciveCommandThreadStruc такая: - хэндл события по которому трэд активизируется; HCommandReciveEvent dd ? - хэндл события, которое устанавливает трэд если все данные успешно получены; HWaitCommandEvent dd ? - адрес буфера получения данных; ReciveCommandBufferOffset dd ? - здесь содержится общее количество полученных данных; BytesCommandRecived dd 0 - и наконец, с какого сокета надо получить данные; ReciveCommandSock dd ? ReciveThread proc parametr:dword mov edi,parametr InfinityLoop: - ждем возникновения события, что данные можно принимать; call WaitForSingleObject,dword ptr [edi],-1 - настраиваем esi на место, куда данные будут считаны - адрес буфера+количество полученных ранее; mov esi,[edi+8] add esi,[edi+12] - получаем не более 4096 байт; call recv,dword ptr [edi+16],esi,4096,0 - прибавляем к уже полученным ранее, полученные в данный момент; add [edi+12],eax - в ebx заносим хэндл события, которое надо установить, если получены все нужные данные; mov ebx,[edi+4] - если мы получаем данные по соединению данных то идем к проверке конца получения данных по соединению данных, иначе - проверяем получили ли мы все данные по управляющему соединению; cmp edi,offset ReciveDataThreadStruc je comparefordata - по управляющему соединению получены все данные в случае если последние байты ответа 0dh, 0ah, что мы и проверяем; mov eax,[edi+12] mov esi,[edi+8] cmp byte ptr [esi+eax-1],10 je short CallEvent jmp InfinityLoop comparefordata: - по соединению данных получено все, если количество полученных байт = длине файла; mov eax,[edi+12] cmp FileLenght,eax jne InfinityLoop CallEvent: - в случае если все данные получены выставляем соответствующее событие; call SetEvent,ebx jmp InfinityLoop ReciveThread endpВернемся теперь к основному потоку, мы успешно получили ответ от сервера, в том что он готов к приему команд, теперь мы можем передавать ему команды, в данной реализации за отправку команд серверу отвечает функция SendCommandInSocket, в основном потоке далее мы вызываем эту функцию для отправки серверу последовательно команд: USER, PASS, TYPE, CWD, PORT и LIST. Сама функция выглядит так:
Код (Text):
- принимает аргументами сокет, в который нужно передать команду, и смещение на буфер, в котором содержится команда; SendCommandInSocket proc uses ebx ecx esi edi, hSocket:dword, OutBufOffset:dword - сначала определяем длину команды; mov edi,OutBufOffset push edi mov eax,0ah mov ecx,100 repne scasb sub edi,OutBufOffset mov ecx,edi pop esi push edi - переносим команду в буфер для приема ответов для сервера, сделано это для того, что бы потом его можно было сохранить в удобочитаемом виде лога; mov edi,ReciveCommandBufferOffset add edi,BytesCommandRecived rep movsb pop edi add BytesCommandRecived,edi - посылаем команду в сокет; call send,hSocket,OutBufOffset,edi,ebx - ждем ответа сервера, с помощью уже описанной выше функции WaitAnswerRecive; mov eax,5001 Wait2Answer: dec eax push eax call WaitAnswerRecive or eax,eax jnz ErrorProcessed - ответ получен, ищем первый байт ответа, мы его ИЩЕМ, а не просто используем смещение на конец предпоследнего полученого сообщения, потому, что нами может быть получено в одном сеансе получения данных обновременно два ответа от сервера. Уясните себе этот момент. mov edi,ReciveCommandBufferOffset mov ecx,BytesCommandRecived dec ecx dec ecx add edi,ecx mov al,0ah std repne scasb cld xor eax,eax - проверяем первый символ ответа; mov cl,[edi+2] cmp cl,'1' - если это "1" то ждем еще одного сообщения от сервера jz Wait2Answer cmp cl,'3' - если ответ больше "3" - произошла ошибка; jna NoErrorProcessed call MessageBoxA,newhwnd,edi,offset ErrorCap,40h ErrorProcessed: xor eax,eax inc eax NoErrorProcessed: ret SendCommandInSocket endpНеобходимо учесть еще одну вещь – перед отправкой команды PORT, нам надо создать слушающий сокет, это мы делаем с помощью вызова процедуры CreateListenSock.
Код (Text):
CreateListenSock proc pushad - создаем сокет; call socket, AF_INET, SOCK_STREAM, IPPROTO_TCP mov datasock,eax - переводим его в не-блокирующий режим, указывая, что хотим получать в процедуре окна сообщения о подтверждении приконекчивания к этому сокету, о поступлении новых данных, и о закрытии соединения; call WSAAsyncSelect, datasock, newhwnd, WM_DATASOCK, FD_ACCEPT+FD_READ+FD_CLOSE - ввязываем сокет с локальным адресом; mov sin_port,0 ; указываем ноль, в этом случае система даст нам ; первый свободный порт mov sin_family,AF_INET mov sin_addr,INADDR_ANY call bind, datasock, offset sockaddr_in, 16 - получаем инфу о сокете; call getsockname,datasock,offset sockaddr_in,offset szSockaddr_in - преобразуем номер порта к нормальному виду; xor eax,eax mov ax,sin_port call ntohs,eax push eax shr eax,8 - дальше преобразуем номер порта в символы ASCII; call DECtoASCII,eax,PortInPort - и записываем их в шаблон команды PORT mov al,',' stosb pop eax and eax,0ffh call DECtoASCII,eax,edi mov ax,0a0dh stosw mov esi,PortInPort - слушаем сокет; call listen, datasock, 1 popad ret CreateListenSock endpИтак последней отправленной командой была команда LIST, после нее на соединение данных должен прийти список файлов текущей директории, соответственно после отправки сообщения нам необходимо подождать пока этот список будет нами получен, т.к. даже если сервер отправил нам сообщение о том, что он успешно завершил отправку всех данных это совсем не значит, что наш поток уже все отработал и получил, поэтому мы ожидаем окончания получения функцией WaitTransferComplete.
Код (Text):
- получает в качестве аргумента в течении которого мы буде ждать ОКОНЧАНИЯ получения данных, после того, как соединение будет закрыто со стороны сервера. WaitTransferComplete proc uses ecx edi, TimeToWaitEndTransfer:dword WaitProgress: - ждем установления события закрытия соединения со стороны сервера, оно устанавливается в главной процедуре окна; call WaitForSingleObject,HWaitCloseEvent,-1 - дальше ждем, события успешного получения данных нашим потоком, которое в нем и устанавливается; call WaitForSingleObject,HWaitDataEvent,TimeToWaitEndTransfer or eax,eax jz CloseDataSocks - был таймаут, и если мы получаем директорию, то выходим без ошибки, т.к. при получении директории у нас всегда таймаут, потому, что мы заранее не знаем количество получаемых байт и поток получения не может установить событие успешного получения; cmp TimeToWaitEndTransfer,1000 ;если ждем каталог jz CloseDataSocks call MessageBoxA,newhwnd,offset ErrTimeOutCommand,offset ErrorCap,40h CloseDataSocks: - сбрасываем событие успешного получения; call ResetEvent,HWaitDataEvent - закрываем соединение со своей стороны; call closesocket,ReciveDataSock call closesocket,datasock ret WaitTransferComplete endpВ случае успешного завершения процедуры выше в буфере приема данных будет лежать таблица каталога. Ниже по программе мы обрабатываем полученную таблицу и по очереди получаем все найденные в ней файлы, получение файла ничем не отличается от получения директории, поэтому здесь этого я описывать не буду. После того, как все файлы были получены и сохранены мы закрываем управляющее соединение и завершаем поток.
Заключение.
Мы разобрали основные принципы работы с протоколом FTP со стороны клиента, конечно же были затронуты далеко не все аспекты этой задачи. Например не была рассмотрена отправка файлов на сервер, но думаю, внимательно изучив материал выше, а так же прилагающийся исходный код, можно без проблем сделать и это, пусть дальнейшее изучение протокола FTP со стороны сервера будет вашим «домашним заданием».
Исходник здесь. © Mad_C
FTP-протокол + WinSocks на примере простого FTP-клиента (зеркала)
Дата публикации 13 июн 2004