Поиск по
сайту:
Комплектующие к кассам. Продаем вакуум-флюоресцентные дисплеи покупателя в Питере со склада. | Запчасти Chevrolet Корея, Европа - шевроле лачети.
Делфи - Статьи - Как научить программу общаться
Предисловие
В этой статье я расскажу о том, как мне пришлось решать достаточно простую,
но вместе с тем интересную задачу. Смысл её заключался в том, чтобы обеспечить
передачу файла по локальной сети на несколько машин по списку. При этом на всех
компьютерах файл может располагаться в различных каталогах и пользователь
компьютера, принимающего файл, не должен никоим образом участвовать в процессе.
Практически стояла задача обеспечить автоматизированную рассылку обновления
софта и антивирусных баз. Решать задачу пришлось в спешке, поэтому был
избран самый простой путь - использовать \"троянообразный\" сервер на машинах
пользователей и клиент с простенькой базой данных, который по очереди
соединяется с серверами и выполняет необходимые действия. Результатом работы
стало реальное клиент-серверное приложение, программа \"Админ-рассылка\",
бета-версию которой Вы можете загрузить на сайте . В качестве платформы для
разработки был использован Borland Delphi 7, база данных - локальная с
использованием библиотеки firebird - gds32.dll. В работе я использовал лучшего
друга всех программистов - internet
Часть 1: Троян на службе сисадмина.
Как уже отмечалось, пользователь в результате работы программы должен
принимать файл, не подозревая об этом. Следовательно, на его машине должно
скрытно выполняться приложение-сервер, открывающее определённый порт для приёма.
То есть, придется в наших (исключительно мирных) целях использовать трояна.
Описание технологии построения троянского приложения я пропущу, так как это
подробно описано на www.miterx.users.kemcity.ru.
Отмечу лишь то, что я добавил от себя. В статье, о которой я упоминал, троян
тихий и послушный: он всего лишь выполняет команду. Мне же пришлось учить
серверную и клиентскую часть приложения полноценному общению. В своей работе
для связи приложений я использовал компоненты ServerSocket и ClientSocket,
которые реализуют асинхронный обмен данными через порт. Не слишком углубляясь в
теорию протоколов, обрисую этот процесс таким образом. Приложения посылают пакет
данных, и продолжают работать, независимо от того, получен их пакет или нет. При
этом, даже если пакет был принят благополучно (а в нормально работающей сети это
бывает ;)) нельзя быть уверенным, КОГДА его приняли. Такая особенность обмена
данными требует подтверждения готовности к приёму сообщений, вроде того, как
принято общаться по рации. Но обо всём по порядку. В серверной части на
форму кладём компонент ServerSocket и прописываем ему порт, который он будет
слушать. Свойство Active ставим false. Обрабатываем события:
procedure TForm1.ServerSocket1ClientConnect(Sender: TObject; Socket:
TCustomWinSocket); begin
ServerSocket1.Socket.Connections[0].SendText(version); old:=false; end;
(При подключении клиента сервер шлёт ему свою версию и устанавливает
глобальную переменную old:=false) Версию нужно знать для того, чтобы можно было
организовать обновление серверной части на машинах пользователей. Переменная old
в дальнейшем будет использоваться, чтобы решить, как относиться к файлу с тем же
именем, если он уже лежит в месте назначения. Если old = false, то файл
заменяем, если true, значит дописываем.
procedure TForm1.FormCreate(Sender: TObject); begin regwrite
(Application.ExeName); // Простенькая процедура работы с реестром моего
производства. ServerSocket1.Active:=true; rez:=\'диалог\'; end;
(При создании формы приложения прописываемся в автозапуске, открываем
порт и устанавливаем глобальную переменную rez). Об этой переменной расскажу
подробнее. Она определяет режим, в котором работает наш сервер. В зависимости от
заранее установленного режима сервер по разному относится к порции принимаемых
данных. В режиме \"диалог\" данные - это команды, в режиме \"файл\" - порция
данных файла и т.п. Теперь самое главное событие - получение данных от клиента.
Рассмотрим эту процедуру по частям в зависимости от режима:
procedure TForm1.ServerSocket1ClientRead(Sender: TObject; Socket:
TCustomWinSocket); .... if rez=\'диалог\' then
begin clTMsg:=ServerSocket1.Socket.Connections[0].ReceiveText; if not
(clTMsg=\'\') then clMsg:=StrToInt(clTMsg); case clMsg of 1:
begin rez:=\'путь\'; ServerSocket1.Socket.Connections[0].SendText(\'1\'); end; 3:
begin rez:=\'размер\'; ServerSocket1.Socket.Connections[0].SendText(\'3\'); end; 5:
begin rez:=\'файл\'; ServerSocket1.Socket.Connections[0].SendText(\'5\'); end; end; end; end; .... end;
Эта часть процедуры выполняется тогда, когда сервер работает в режиме
диалога. В строковую переменную clTMsg заносится строка, полученная от клиента.
Если строка не пустая, она превращается в число (я использовал числовые команды
для общения клиента с сервером). Затем сервер переключается в требуемый режим и
отвечает клиенту.
.... if rez=\'путь\' then
begin path:=ServerSocket1.Socket.Connections[0].ReceiveText; ServerSocket1.Socket.Connections[0].SendText(\'2\'); if
not DirectoryExists (ExtractFilePath(path)) then
ForceDirectories(ExtractFilePath(path)); rez:=\'диалог\'; end; ....
В режиме \"путь\" сервер присваивает полученную от клиента строку
переменной path, затем отвечает клиенту, что путь получен успешно. Далее
выполняется проверка на наличие каталога, в который нужно будет положить файл, и
если каталога нет, он тут же создаётся. И, наконец, сервер переключает себя в
режим диалога.
.... if rez=\'размер\' then
begin sz:=StrToInt(ServerSocket1.Socket.Connections[0].ReceiveText); ServerSocket1.Socket.Connections[0].SendText(\'4\'); rez:=\'диалог\'; end; ....
В режиме \"размер\" полученные данные преобразуются в число и
присваиваются переменной sz, которая хранит размер передаваемого файла. Дальше
следует ответ серверу и возврат в режим диалога.
.... if rez=\'файл\' then begin lr:=0; while (lr < sz) do
begin l:=Socket.ReceiveLength; GetMem(buf,l+1); Socket.ReceiveBuf(buf^,l); try if
(FileExists(path) and old) then
begin src:=TFileStream.Create(path,fmOpenReadWrite); src.Seek(0,soFromEnd); end else
begin src:=TFileStream.Create(path,fmCreate); old:=true; end; src.WriteBuffer(buf^,l); lr:=lr+l; except FreeMem(buf); src.Free; sz:=0; rez:=\'диалог\'; ServerSocket1.Socket.Connections[0].SendText(\'9\'); end; FreeMem(buf); src.Free; end; sz:=0; ServerSocket1.Socket.Connections[0].SendText(\'6\'); rez:=\'диалог\'; end; ....
В режиме \"файл\" принимаемые сервером данные рассматриваются как части
файла. Они через буфер дописываются к файлу, до тех пор, пока суммарная длина
принятых данных не сравняется с ранее установленной длиной файла. Практически
это реализовано следующим образом: - переменной lr, которая хранит размер
уже принятых данных, присваивается нулевое значение. - выполняем цикл до тех
пор пока lr меньше размера файла - занимаем память под буфер, для чего
получаем длину текущей порции данных (переменная l) - читаем порцию данных
из сокета в буфер (переменная buf) Дальше пытаемся выполнить запись: -
если файл уже имеется и получаемый файл не должен его заменять (вот и
пригодилась глобальная переменная old) то открываем файл для записи и ставим
указатель в конец файла - если файла ещё нет или его нужно переписать
(old=false) создаём файл - так или иначе, в полученный файловый поток пишем
содержимое буфера, прибавляем размер текущего блока данных к общей длине
принятого файла и повторяем цикл со следующей порцией данных. Если запись не
удалась (блок except), освобождаем память, переключаемся в режим диалога и
отправляем клиенту сообщение \"9\" (\"найн\" то есть ничего не вышло;)) Если
всё прошло успешно, говорим клиенту \"6\". Вот такой общительный троян
получился! Я не стал описывать второстепенные вещи, типа обработки выхода,
разрыва соединения и т.п. Вместо этого лучше давайте рассмотрим второго
участника \"сокетной беседы\" - программу-клиента.
Часть 2: Файловый почтальон Данные о пользователях, которые должны
получить файл я решил хранить в базе данных. База состоит из единственной
таблицы USERS с полями, в которых хранится по порядку ip-адрес, имя хоста,
версия сервера, локальный путь на машине пользователя, поле \"отправка\" и
примечание. О том, как подключать локальную базу к программе писать не буду: это
легко найти в других источниках. Базу я создавал с помощью прекрасной утилиты
IBExpert, используя библиотеку gds32.dll из инсталляции firebird, для работы с
полученной базой использовались компоненты из вкладки interbase. Такая схема не
требует установки на компьютере сервера баз данных, что, согласитесь, очень
удобно. База данных заполняется перед началом работы программы вручную. В поле
\"отправка\" 1 означает, что файл успешно передан, 0 - требуется передать файл.
Первую в работе кнопочку \"Выбрать файл для рассылки\" первой и
обрабатываем:
procedure TForm1.SelectFileButtonClick(Sender: TObject); begin if
OpenDialog1.Execute then begin filename :=
OpenDialog1.FileName; SendButton.Enabled:=true; StatusBar1.Panels.Items[0].Text:=\'Отправляем
файл \'+filename; end; end;
Из OpenDialog-а получаем в переменную filename полное имя файла, который
нужно отправлять. Включаем (выключенную по умолчанию) кнопку \"Выполнить
рассылку\" и обеспечиваем интерфейс с пользователем ;) выводя в StatusBar строку
с именем отправляемого файла. Кнопочки \"Все отправлять\" и \"Все не
отправлять\" в принципе обрабатываем одинаково:
procedure TForm1.Oll_0_ButtonClick(Sender:
TObject); begin IBTable1.First; while not IBTable1.EOF do
begin IBTable1.Edit; IBTable1.FieldByName(\'SEND\').AsInteger:=0; IBTable1.Post; IBTable1.Next; end; end; Проходим
по таблице и в поле \"отправка\" проставляем 0 или 1 в зависимости от нажатой
кнопки. Кнопка \"Установить пути по умолчанию\" тоже не блещет интеллектом:
Также происходит проход по всем записям таблицы и редактирование поля с путём
размещения файла. Только строка с путём берётся из InputBox-а. Понятно, что если
в вашей сети три компьютера, эта кнопка бесполезна. Но если их, как у меня, 140…
Итак, теперь, отбрасывая всё незначительное, мы вплотную приближаемся к
\"главной кнопке\". Её код:
procedure TForm1.SendButtonClick(Sender:
TObject); begin IBTable1.First; rez:=\'диалог\'; NextServ(); end;
Становимся в начало таблицы, устанавливаем уже знакомую нам переменную
rez, и...
procedure NextServ(); label nx; begin srcfile :=
TFileStream.Create(filename,fmOpenRead); nx: if
Form1.IBTable1.FieldByName(\'SEND\').AsInteger=0 then
begin Form1.ClientSocket1.Address:=Form1.IBTable1.FieldByName(\'IP\').AsString; path:=Form1.IBTable1.FieldByName(\'PATH\').AsString; Form1.StatusBar1.Panels.Items[0].Text:=\'Обрабатываем
сервер \' +
Form1.ClientSocket1.Address; Form1.ClientSocket1.Open; Form1.ClientSocket1.Socket.SendText(\'0\'); rez:=\'версия\'; end else
begin Form1.IBTable1.Next; if not Form1.IBTable1.Eof then goto
nx; end; end;
Да, я знаю, что использовать goto это дурной тон. Но если надо быстро, и
вообще… Итак: читаем файл в поток, затем если в поле \"отправка\" стоит 0,
устанавливаем сокету ip из базы, читаем в переменную path путь из базы, пишем в
StatusBar, какой сервер в данный момент обрабатываем, подключаемся и шлём нашему
дорогому троянчику нолик. Мы уже знаем, что в ответ на наш запрос сервер пошлёт
свою версию, поэтому и переходим в соответствующий режим. Если сервер обработан,
идём дальше. Тут я хочу обратить внимание на одну пикантную особенность. В
этой процедуре мы только запускаем обмен данными с серверами, а сам обмен будет
реализован совсем в другом месте. Вот в этом:
procedure TForm1.ClientSocket1Read(Sender: TObject; Socket:
TCustomWinSocket); begin if rez=\'диалог\' then
begin servMsg:=StrToInt(ClientSocket1.Socket.ReceiveText); case servMsg
of 1: begin StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+\'
готов принимать путь...\'; sleep(800); ClientSocket1.Socket.SendText(path+
\'\\\' +ExtractFileName(filename)); end; 2:
begin StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' путь
получил!\'; sleep(800); ClientSocket1.Socket.SendText(\'3\'); end; 3:
begin StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+\' готов
принимать
размер...\'; sleep(800); ClientSocket1.Socket.SendText(IntToStr(srcfile.Size)); end; 4:
begin StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' размер
получил!\'; sleep(800); ClientSocket1.Socket.SendText(\'5\'); end; 5:
begin StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' готов
принимать
файл...\'; sleep(800); ClientSocket1.Socket.SendStream(srcfile); end; 6:
begin StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' файл
получил!\'; sleep(800); ClientSocket1.Active:=false; IBTable1.Edit; IBTable1.FieldByName(\'SEND\').AsInteger:=1; IBTable1.Post; IBTable1.Next; NextServ(); end; 9:
begin StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' сообщил об
ошибке!\'; sleep(800); ClientSocket1.Active:=false; IBTable1.Edit; IBTable1.FieldByName(\'SEND\').AsInteger:=9; IBTable1.Post; IBTable1.Next; NextServ(); end; end; end; ....
Это обработка получения сообщения от сервера в случае, если клиент находится
в режиме диалога. Как и в случае с трояном, разбираем сообщение через case,
пишем в StatusBar перевод сообщения на человеческий язык, и если сообщение
означало готовность сервера что-то принять, тут же ему это и отправляем. Если
сервер своим сообщением подтверждает получение, мы не даём ему расслабляться, и
шлём команду приготовиться (т.е. перейти в соответствующий режим) к приёму
следующей информации. Из листинга вполне понятно, что за чем отправляем. А
sleep(800) нужно, как вы уже, наверное догадались, для того, чтобы пользователь
успевал читать в StatusBar-е. Особое внимание уделяется двум сообщениям
сервера: 6 и 9. При получении шестёрки отключаемся от сервера, пишем в базу
единичку (типа файл отправлен), и запускаем уже знакомую нам процедуру NextServ
уже для следующего сервера в списке. Почти также реагируем на девятку, только в
базе возникнет не благополучная \"1\" а тревожная \"9\", сообщающая о том, что с
сервером что-то не то. Есть у нас ещё один режим. В нём мы принимаем версию
сервера и заносим её в базу:
.... if rez=\'версия\' then
begin serVer:=ClientSocket1.Socket.ReceiveText; StatusBar1.Panels.Items[0].Text:=\'Получен
ответ от \'+ClientSocket1.Address+\'; версия сервера \'+
serVer; IBTable1.Edit; IBTable1.FieldByName(\'VERSION\').AsString:=serVer; IBTable1.Post; sleep(800); ClientSocket1.Socket.SendText(\'1\'); rez:=\'диалог\'; end;
Тут, как можно без труда догадаться, переменная serVer хранит эту самую
версию. После сообщения о версии и записывания её в базе, отсылаем серверу
единицу и переходим в режим диалога, который мы уже разобрали выше. И
последнее: ошибка может возникнуть, если сервер вообще недоступен по какой-либо
причине. Обрабатываем это так:
procedure TForm1.ClientSocket1Error(Sender: TObject; Socket:
TCustomWinSocket; ErrorEvent: TErrorEvent; var Errorpre:
Integer); begin Errorpre:= 0;
Form1.StatusBar1.Panels.Items[0].Text:=\'Подключиться к
\'+Form1.ClientSocket1.Address+\' не удалось!\'; Form1.IBTable1.Edit;
Form1.IBTable1.FieldByName(\'SEND\').AsInteger:=0;
Form1.IBTable1.Post; Form1.IBTable1.Next; NextServ(); end;
Тут избавляемся от неприятных сообщений об ошибке (Errorpre:= 0) и
заменяем их культурной записью в StatusBar. Потом устанавливаем ноль в таблицу и
переходим к обработке следующего сервера. Вот, собственно, и вся программа.
За скобками оставим подключение к базе при запуске и отключение при выходе,
прочие мелочи. Главное, что я хотел проиллюстрировать на этом примере -
использовать асинхронные сокеты можно не только для посылки односторонних
команд. Достаточно простой приём, использованный мной, можно существенно
оптимизировать, выделить и сделать методом класса. Я уже молчу о том, что
обрабатывать сервера один за другим совсем не обязательно: стоит только разнести
сервера на разных машинах по разным номерам портов - и вот вам одновременная
беседа со всеми компьютерами в списке. Есть другие идеи? Великолепно. Я тут как
раз планирую писать следующую версию, и у меня много места в списке авторов
;)