Передмова
У цій статті я розповім про те, як мені довелося вирішувати досить просту, але разом з тим цікаву задачу. Сенс її полягав у тому, щоб забезпечити передачу файлів по локальній мережі на кілька машин за списком. При цьому на всіх комп’ютерах файл може розташовуватися в різних каталогах і користувач комп’ютера, яка приймає файл не повинен жодним чином брати участь у процесі. Практично стояло завдання забезпечити автоматизовану розсилку оновлень софта і антивірусних баз.
Вирішувати завдання довелося в поспіху, тому було обрано найпростіший шлях — використовувати «троянообразный» сервер на машинах користувачів і клієнт з простенької базою даних, який по черзі з’єднується з серверами і виконує необхідні дії.
Результатом роботи стало реальне клієнт-серверний додаток, програма «Адмін-розсилка», бета-версію якої Ви можете завантажити на сайті www.nox.in.ua. В якості платформи для розробки був використаний Borland Delphi 7, база даних — локальна з використанням бібліотеки firebird — gds32.dll. В роботі я використовував кращого друга всіх програмістів — internet, зокрема особливу допомогу мені надали матеріали з сайтів: www.club.shelek.com, www.fido-online.com, www.delphi.vov.ru, www.miterx.users.kemcity.ru та інших.

Частина 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 of clMsg
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 of servMsg
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. Потім встановлюємо нуль в таблицю і переходимо до обробки наступного сервера.
Ось, власне, і вся програма. Залишимо За дужками підключення до бази при запуску і відключення при виході, інші дрібниці. Головне, що я хотів проілюструвати на цьому прикладі — використовувати асинхронні сокети можна не тільки для здійснення односторонніх команд. Досить простий прийом, використаний мною, можна істотно оптимізувати, виділити і зробити методом класу. Я вже мовчу про те, що обробляти сервера один за іншим зовсім не обов’язково: варто тільки рознести сервера на різних машинах з різними номерами портів — і ось вам одночасна розмова з усіма комп’ютерами в списку. Є інші ідеї? Чудово. Я тут якраз планую писати наступну версію, і у мене багато місця в списку авторів 😉