Сообщений 5    Оценка 210 [+1/-0]         Оценить  
Система Orphus

Пример реализации inetd для Windows

Автор: Александр Пристенский
TELMA Ltd.

Источник: RSDN Magazine #2-2005
Опубликовано: 12.07.2005
Исправлено: 10.12.2016
Версия текста: 1.0
ВВЕДЕНИЕ
МНОГОПОТОЧНЫЙ СЕРВЕР
Коммуникация между процессами
ТЕСТИРОВАНИЕ
Заключение
Безопасность
Возможные улучшения и обращение к читателю

Исходные тексты
Исполняемый файл

ВВЕДЕНИЕ

Данный простой пример реализации inetd для Windows представляет собой многопоточный сервер, запускающий дочерние процессы, ввод-вывод которых перенаправляется на созданный при соединении с клиентом сокет. Дочерние процессы представляют собой консольные приложения, которые абстрагированы от работы с сокетами, а вместо этого работают с stdin и stdout, что позволяет разработчику сконцентрироваться на реализации протоколов сеансового уровня модели OSI (SMTP, POP3, IMAP, HTTP, etc.) вместо переписывания кода работы с сокетами.

Таким образом, например, в UNIX/Linux реализованы сервисы echo, chargen, pop3, imap и др. Можете ради интереса запустить /usr/sbin/pop3 и вы увидите приглашение pop3-сервера на консоли, однако вы НЕ обнаружите с помощью команды netstat открытого порта 110 :)

# man 8 xinetd

xinetd выполняет те же функции, что и inetd: он запускает программы, которые предоставляют интернет-сервисы. Вместо запуска этих сервисов во время инициализации системы и их бездействия до запроса на соединение, стартует только xinetd и «слушает» все порты сервисов, указанные в конфигурационном файле. Когда приходит запрос, xinetd запускает необходимый сервер. Из-за используемого в нем подхода xinetd (так же, как и inetd) также называют супер-сервером.

(c) Copyright 1992 by Panagiotis Tsirigotis

(c) Sections Copyright 1998-2001 by Rob Braun

Достоинства:

Недостатки:

На рисунке представлена упрощённая схема взаимодействия клиентов с консольными приложениями через inetd (Windows-реализация) :


Рисунок 1. Взаимодействие клиентов с консольными приложениями через inetd (под Windows).

Мой пример намеренно упрощён: он слушает только один порт и позволяет запускать несколько экземпляров одного типа сервиса (например, для тестирования я использовал cmd.exe). Однако никто не запрещает запустить нужное количество экземпляров inetd с разными параметрами. Основные задачи данной статьи: показать начинающим в сетевом программировании, как создать многопоточный сервер, и специфику осуществления взаимодействия консольных дочерних процессов с сокетом под Windows.

МНОГОПОТОЧНЫЙ СЕРВЕР

Данная задача реализуется достаточно просто, однако работа с сокетами под Windows имеет свои особенности. Свои дополнительные комментарии я вставил между блоками исходного кода. Надеюсь, что это не отразится на качестве статьи. Для более удобного запоминания обязательных шагов при создании серверного приложения в комментариях к самому коду я ввёл нумерацию вида «1.X.».

      /*
     INETD v1.3 для WINDOWS. Автор: Александр Пристенский [a.k.a. RekoD]
*/
      #include
      "stdafx.h"
      #include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <process.h>
#include <windows.h>
#include <winsock2.h>
#include <algorithm>

#pragma comment (lib, "ws2_32.lib")

#define BUFSIZE 255
#define KEEPALIVE 10
#define MAXCONNQUEUE 5

bool telnet = false; // соединяется ли клиент по протоколу telnet?#define BZERO(a) memset(a, 0, sizeof(a))

typedefstruct _t_param
{
  SOCKET s;
  char   exe[MAX_PATH];
}
THREAD_PARAM;

unsignedint__stdcall ServerThread(LPVOID tParam);

int main(int argc, char* argv[])
{
  WSADATA WSAData;
  SOCKET listen_socket, server_socket;
  SOCKADDR_IN server_addr, temp_addr;
  int accepted_len = sizeof(temp_addr);

  // Проверка параметровif (argc < 3)
  {
    printf("\nUsage: %s PORT CMDLINE [-FF]\n"
      "Example: %s 23 c:\\winnt\\system32\\cmd.exe -FF\n"
      "Flag -FF should be used if client will connect using TELNET protocol.",
      argv[0], argv[0]);
    return -1;
  }

  if (atoi(argv[1] < 1) || atoi(argv[1]) > 65535)
  {
    printf("\nIncorrect port number. Value must be in range 1-65535");
    return -1;
  }

  if (argc > 3 && !strcmp(argv[3], "-FF"))
  {
    telnet = true;
  }
...

При программировании сокетов под Windows надо инициализировать систему winsock. В данном случае используется winsock 2.0.

      // 1.1. Инициализация WINSOCK
      if (WSAStartup(MAKEWORD(2,0), &WSAData) != 0)
   {
      printf("\nWSAStartup() failed with error: %i", WSAGetLastError());
      return -1;
   }

   // 1.2. Создание "слушающего" сокетаif ((listen_socket = socket(AF_INET, SOCK_STREAM, 0)) == INVALID_SOCKET)
   {
      printf("\nsocket() failed with error: %i", WSAGetLastError());
      return -1;
   }

Хочется особо отметить установку опции REUSE ADDRESS для «слушающего» сокета. Этот шаг не является обязательным, и новички в сетевом программировании его пропускают (сам поначалу так делал :-) ), а зачастую и не подозревают о существовании такой опции сокета. Эта опция используется как под Windows, так и под UNIX/Linux. Рассмотрим подробнее, что же происходит: допустим, к серверу подсоединилось некоторое количество (достаточно одного) клиентов, и в это время сервер необходимо аварийно перезапустить (этот сервер – консольный, и закрытие окна есть его аварийное завершение). При этом «слушающий» сокет остаётся некоторое время (в зависимости от типа ОС) в состоянии TIME_WAIT. Если опция REUSE ADDRESS не выставлена, ОС не может позволить серверу использовать сокет с таким же адресом и функция bind() завершается ошибкой.

      // Установка опции: REUSE ADDRESS.
      // Если сервер "упадёт" при подсоединённых клиентах
      // он сможет снова стартовать используя тот же порт.
      bool opt = true;

if(SOCKET_ERROR == setsockopt(
listen_socket, SOL_SOCKET, SO_REUSEADDR, (char *)&opt, sizeof(opt)))
{
  printf("\nsetsockopt(SO_REUSEADDR) failed with error: %i", 
    WSAGetLastError());
  return -1;
}

// 1.3. Привязка сокета к локальному адресу
server_addr.sin_family      = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port        = htons(atoi(argv[1]));

if(bind(listen_socket, (struct sockaddr *)&server_addr, 
  sizeof(server_addr)) == SOCKET_ERROR)
{
  printf("\nbind() failed with error: %i", WSAGetLastError());
  return -1;
}

Функция listen() используется для включения прослушивания на серверном сокете запросов соединения от клиента. Второй параметр – длина очереди, т.е. если одновременно пытаются соединиться 5 клиентов, то 6-й клиент соединиться не сможет, и должен будет повторить попытку позднее.

Само соединение реализуется функцией accept(). Данная функция блокирует работу цикла принятия соединений до тех пор, пока не будет получен запрос на соединение от клиента. Как только запрос на соединение получен – accept() возвращает временный сокет, который и будут использовать сервер и клиент для диалога. Если лень писать многопоточный сервер, на этом этапе можно запустить цикл работы с сокетом, используя функции recv(), send() и select(), однако такой сервер сможет работать только с одним клиентом одновременно.

Для организации многопоточного сервера необходимо всего лишь вызвать потоковую функцию и передать ей сокет (ну и при необходимости что-нибудь ещё), а внутри функции организовать цикл работы с сокетом всё теми же recv(), send() и select().

      // 1.4. Начать прослушивать серверный сокет
      if(listen(listen_socket, MAXCONNQUEUE) == SOCKET_ERROR)
  {
    printf("\nlisten() failed with error: %i", WSAGetLastError());
    return -1;
  }

  // 1.5. Цикл принятия соединений от клиентовwhile ((server_socket = accept(
listen_socket, (struct sockaddr *) &temp_addr, 
    (int *)&accepted_len)) != INVALID_SOCKET)
  {
    // Какой хост соединился с сервером?
    SOCKADDR peer;
    int peerlen = sizeof(peer);
    
    if (0 == getpeername(server_socket, &peer, &peerlen))
    {
      printf("\nConnect from %s:%i", 
        inet_ntoa(((SOCKADDR_IN *)&peer)->sin_addr), 
        ntohs(((SOCKADDR_IN *)&peer)->sin_port));
    }

    // Запуск потока для работы с сокетом и передача параметров// (динамическая память будет освобождена при выходе из потока).
    HANDLE hThread = NULL;

    THREAD_PARAM* t_param = NULL;

    t_param = new THREAD_PARAM;

    if(NULL == t_param)
    {
      printf("\n memory allocation error!");
      break;
    }

    t_param->s = server_socket;
    BZERO(t_param->exe);
    strcpy(t_param->exe, argv[2]);

    // создаём поток с помощью _beginthreadex()if(NULL == (hThread = (HANDLE)_beginthreadex( 
NULL, 0, ServerThread, t_param, 0, NULL)))
    {
      printf("\nCreateThread() failed with error: %i", GetLastError());
    }
    else
    {
      CloseHandle(hThread); // HANDLE потока нам больше не нужен
    }
  }
  closesocket(listen_socket);
  return 0;
}

На промежуточном этапе тестирования я применял потоковую функцию, которая реализует нечто вроде сервиса echo:

DWORD WINAPI EchoThread(LPVOID tParam)
{
  THREAD_PARAM *param = (THREAD_PARAM *)tParam;
  char buf[255];
  int  received = 0;

  send(param->s, "echo service:\r\n", sizeof("echo service:\r\n"), 0);
  while (1)
  {
    if (0 != (received = recv(param->s, buf, sizeof(buf), 0)))
      send(param->s, buf, received, 0);
    elsebreak;
  }

  // Закрыть сокет и освободить память переданной структуры
  shutdown(param->s, SD_BOTH);
  closesocket(param->s);
  delete param;
  return 0;
}

Коммуникация между процессами

Теперь мы подошли к самому интересному вопросу: как заставить дочерний процесс работать с сокетом?

UNIX/Linux не видят разницы между файловыми дескрипторами и сокетами и представляют их как целые числа (int). Потоки ввода-вывода STDIN, STDOUT и STDERR также имеют стандартные номера 0, 1 и 2 соответственно (справедливо и для windows). Для перенаправления используется функция dup2(int handle1, int handle2), которая заставляет handle2 ссылаться на тот же файл, что и handle1, т.е. для перенаправления stdin на сокет s надо всего лишь выполнить dup2(0, s).

В Windows всё несколько сложнее, и приходится использовать каналы (pipes).

      // Потоковая функция, которая запускает дочерний процесс
      // и перенаправляет его STDIN и STDOUT на сокет
      unsigned
      int
      __stdcall ServerThread(LPVOID tParam)
{
  THREAD_PARAM *param = (THREAD_PARAM*)tParam;

  char buf[BUFSIZE];
  // временная переменная для хранения результатов вызовов функцийint res;

  unsignedlong received = 0;
  unsignedlong sent = 0;

  fd_set  read_s;
  timeval time_out;

  time_out.tv_sec  = 0;
  time_out.tv_usec = 5000; // 0.005 сек.// для keep-alive проверок
  SYSTEMTIME oldtime, curtime;
  GetSystemTime(&oldtime);
  GetSystemTime(&curtime);

  // Структура STARTUPINFO используется в CreateProcess()// для задания атрибутов главного окна создаваемого процесса.
  // В данном случае я задаю новые STDIN, STDOUT и STDERR,
  // а также указываю опцию SW_HIDE – не показывать окно
  // дочернего процесса (см. инициализацию структуры далее)
  STARTUPINFO si;
  // Структуры SECURITY_ATTRIBUTES и SECURITY_DESCRIPTOR// используются для задания свойств каналов в CreatePipe()
  SECURITY_ATTRIBUTES sa; // Поле sa.bInheritHandle задаёт возможность// наследования HANDLE дочерним процессом.
  SECURITY_DESCRIPTOR sd; // SECURITY_DESCRIPTOR содержит права доступа
// и идентифицирует владельца объекта.// Структура PROCESS_INFORMATION заполняется при создании дочернего
  // процесса и используется в цикле чтения-записи (см. далее)
  // для определения ситуации когда дочерний процесс завершился сам.
  PROCESS_INFORMATION pi;
  // новые STDIN и STDOUT для дочернего процесса
  HANDLE newstdin, newstdout;
  // дескрипторы каналов
  HANDLE read_stdout, write_stdin;  

  unsignedlong quit = 0;
  unsignedlong bread;
  unsignedlong avail;

  // Инициализация дескриптора безопасности
  InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION);
  SetSecurityDescriptorDacl(&sd, true, NULL, false);

  // Инициализация security attributes
  sa.lpSecurityDescriptor = &sd;
  sa.nLength = sizeof(SECURITY_ATTRIBUTES);
  // разрешить наследование дескриптора дочерним процессом
  sa.bInheritHandle = true;

...

В структуре SECURITY_ATTRIBUTES поле bInheritHandle отвечает за возможность наследования HANDLE дочерним процессом.

Далее необходимо создать каналы (pipes) между HANDLES, которые заменят STDIN, STDOUT и STDERR (см. инициализацию структуры STARTUPINFO si) дочернего процесса и HANDLES с которыми мы будем работать с помощью функций ReadFile(), WriteFile() и PeekNamedPipe() в цикле чтения-записи.

      // Создание pipes и перенаправление
  CreatePipe(&newstdin, &write_stdin, &sa, 0);
  CreatePipe(&read_stdout, &newstdout, &sa, 0);

  // Инициализация стартовой информации для дочернего процесса
  GetStartupInfo(&si);
  si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
  si.wShowWindow = SW_HIDE;  // не показывать окно дочернего приложения
  si.hStdOutput = newstdout; // вместо STDOUT использовать newstdout
  si.hStdError  = newstdout; // вместо STDERR использовать newstdout
  si.hStdInput  = newstdin;  // вместо STDIN  использовать newstdin// Запуск дочернего приложения (дочернее приложение - // любое консольное приложение). STDIN и STDOUT перенаправляются // в сокет - задача дочернего приложения лишь реализовывать определённый
  // протокол (HTTP, POP, SMTP и т.д.)if(0 == CreateProcess(
param->exe, NULL, NULL, NULL, true, CREATE_NEW_CONSOLE, 
    NULL, NULL, &si, &pi))
  {
    printf("\nCreateProcess() fails with error: %i", GetLastError());
    return -1;
  }

Цикл чтения-записи работает следующим образом:

Особенно мне хотелось бы остановиться на функциях PeekNamedPipe() и select() без которых нормальная работа данного цикла невозможна (хотя вместо select() и можно использовать неблокирующие сокеты, но на мой взгляд, это более громоздкий подход).

Обе функции осуществляют опрос на предмет поступления новых данных (т.н. polling). Для чего это нужно? Представим, что мы не используем select(). Тогда, дойдя до recv(), цикл остановится и будет ждать данные от клиента, в то время как у сервера могут вдруг появиться новые данные, полученные от дочернего процесса.

Типичный пример: если использовать в качестве дочернего процесса cmd.exe и отправить команду dir, находясь в каталоге с большим количеством файлов, то, отправив порцию данных и дойдя до recv(), сервер останется там в ожидании новой команды от клиента, а клиент, соответственно, получит только часть вывода команды dir.

Несколько слов о keep-alive сообщениях. Допустим у клиента возникли проблемы с соединением - сбой сети, неожиданное закрытие клиентского приложения из-за ошибки и т.д. Цикл чтения-записи будет при этом продолжаться бесконечно – функция select() будет завершаться, и констатировать просто отсутствие данных от клиента, а у сервера данные также закончатся и функция PeekNamedPipe() будет указывать на отсутствие данных для посылки клиенту. Чтобы этого не произошло, применяется keep-alive (посылаются пустые сообщения каждые N секунд). Тогда при отсутствии отклика от клиента на keep-alive сообщение функция recv() завершится с ошибкой WSAECONNABORTED.

      // Цикл чтения-записи
      while(1)
  {  
    // сколько данных осталось принять/отправить?
unsignedlong left; 
    unsignedint  idx;

    // 2.1. Дочерний процесс нормально завершился сам?if (0 == GetExitCodeProcess(pi.hProcess, &quit))
      printf("\nGetExitCodeProcess() fails with error: %i", GetLastError());
    if (STILL_ACTIVE != quit) 
      break;

    // 2.2. Есть ли данные на STDOUT дочернего процесса ?
    BZERO(buf);

    if (0 == PeekNamedPipe(
read_stdout, buf, BUFSIZE - 1, &bread, &avail, NULL))
    {
      printf("\nPeekNamedPipe() fails with error: %i", GetLastError());
      break;
    }

    if (bread > 0)
    {
      // ReadFile() может прочитать меньше, чем есть в STDOUT дочернего // процесса, необходимо организовать цикл, чтобы прочитать все данные.
      left = bread;
      idx  = 0;

      while (left > 0)
      {
        if (0 == (res = ReadFile(
read_stdout, &buf[idx], left, &received, NULL)))
        {
          printf("\nReadFile() fails with error: %i", GetLastError());
          break;
        }
        if(res && (received == 0)) 
          break; // EOF
        left -= received;
        idx  += received;
      }

      left = bread;
      idx  = 0;

      // Если клиент соединился по протоколу TELNET, надо убрать// из полученных данных 0xFF, т.к. это значение TELNET// воспринимает как начало команды.if (true == telnet)
      {
        std::replace(
          (unsignedchar*)buf, (unsignedchar*)(buf + left), 0xFF, 0x20);
      }

      // send() может отправить меньше данных, чем было в буфере,// поэтому необходимо организовать цикл, чтобы отправить все данныеwhile (left > 0)
      {
        if (SOCKET_ERROR == (res = send(param->s, &buf[idx], left, 0)))
        {
          printf("\nsend() fails with error: %i", WSAGetLastError());
          break;
        }

        left -= res;
        idx  += res;
      }    
    }

    // 2.3. Отправка keep-alive каждые KEEPALIVE секунд
    GetSystemTime(&curtime);
    if (abs(curtime.wSecond - oldtime.wSecond) >= KEEPALIVE)
    {
      oldtime = curtime;
      if (SOCKET_ERROR == send(param->s, "", 1, 0))
      {
        printf(
          "\nkeep-alive send(): fails with error: %i", WSAGetLastError());
      }
    }

    // 2.4. Есть ли данные от клиента?
    BZERO(buf);

    FD_ZERO(&read_s);
    FD_SET(param->s, &read_s);
    received = 0;

    if (SOCKET_ERROR == select(0, &read_s, NULL, NULL, &time_out))
    {
      printf("\nselect() fails with error: %i", WSAGetLastError());
      break;
    }

    if (FD_ISSET(param->s, &read_s))
    {
      if (SOCKET_ERROR == (received = recv(param->s, buf, BUFSIZE, 0)))
      {
        if (WSAECONNABORTED == WSAGetLastError())
          // keep-alive - ответ не получен (см. 2.3.)// или соединение оборвалось при пересылке данных
          printf("\nrecv(): client cut the connection.");
        else
          printf("\nrecv() fails with error: %i", WSAGetLastError());
        break;
      }
    }

    // WriteFile() может записать в STDIN дочернего процесса меньше, чем есть// в буфере, поэтому организуем цикл для записи всех данных,
    // принятых recv()
    left = received;
    idx  = 0;

    while (left > 0)
    {
      if (0 == (res = WriteFile(write_stdin, &buf[idx], left, &sent, NULL)))
      {
        printf("\nWriteFile() fails with error: %i", GetLastError());
        break;
      }
      left -= sent;
      idx  += sent;
    }
  } // Цикл чтения-записи (конец)// Если клиент неожиданно оборвёт соединение,// надо уничтожить соответствующий дочерний процесс
  TerminateProcess(pi.hProcess, 0);

  // Закрыть все хэндлы дочернего процесса
  CloseHandle(pi.hThread);
  CloseHandle(pi.hProcess);
  CloseHandle(newstdin);
  CloseHandle(newstdout);
  CloseHandle(read_stdout);
  CloseHandle(write_stdin);

  // Сообщить, кто отключился
  SOCKADDR peer;
  int peerlen = sizeof(peer);
      
  if(0 == getpeername(param->s, &peer, &peerlen))
  {
    printf(
      "\nDisconnect %s:%i", inet_ntoa(((SOCKADDR_IN *)&peer)->sin_addr), 
ntohs(((SOCKADDR_IN *)&peer)->sin_port));
  }

  // Закрыть сокет и освободить память переданной структуры
  shutdown(param->s, SD_BOTH);
  closesocket(param->s);
  delete param;
  param = NULL;

  return 0;
}

Как видно, в цикле чтения-записи есть вложенные циклы для ReadFile(), WriteFile() и send(). Дело в том, что эти функции могут выполнить операцию чтения или записи не полностью (о чём информируют с помощью возврата соответствующих значений). Это и заставляет организовывать циклы для «дочитывания»/«дописывания». К функции recv() данная тактика неприменима, т.к. мы не можем узнать, сколько данных поступает (ну не умеет select() этого делать). Зато можем узнать, сколько данных было реально прочитано из сокета.

ТЕСТИРОВАНИЕ

Для тестирования удобно использовать уже имеющееся в системе консольное приложение, например, cmd.exe. Поскольку _beginthreadex() требует полный путь к исполняемому файлу, строка запуска inetd может выглядеть, например, так:

>inetd 4000 c:\windows\system32\cmd.exe –FF

В качестве клиента я использовал стандартный Telnet:

>telnet 127.0.0.1 4000

При использовании telnet.exe необходимо запускать inetd с флагом –FF, т.к. протокол TELNET воспринимает данные после 0xFF как команды

(IAC – Interprete As Command).

В результате после выполнения команды dir или type (с файлом, содержащим 0xFF) клиент перестаёт реагировать на ввод новых команд (перестаёт посылаться 0x0A a.k.a. <LF>).

Для тестирования приложений (без коррекции ввода-вывода) я настоятельно рекомендую запускать inetd без ключа –FF и использовать в качестве клиента netcat или PuTTY (RAW-соединение).

А вот как это выглядит (для наглядности я запустил inetd и 3 клиента, затем закрыл одного из клиентов, и запустил netstat.exe, чтобы продемонстрировать выделение временных портов функцией accept()):


Рисунок 2.

Как покзано на рисунке 2, при соединении с клиентами сервер выделил временные порты 1048, 1049, 1050 с помощью функции accept(), а после закрытия одного из клиентов порт 1049 был также закрыт (историю соединений видно в окне inetd.exe). Порт же 4000 продолжает ждать соединений (LISTENING).

Заключение

Безопасность

Об этой проблеме я вскользь уже упомянул во введении (там, где описывал недостатки). Как уже говорилось, данный сервер является лишь посредником между клиентом и приложением, предоставляющим сервис, поэтому за уязвимость приложения он ответственности не несёт (и нести не может).

Рекомендую запускать данный сервер с помощью команды «Run As…» от лица непривилегированного пользователя – тогда и дочерние процессы, предоставляющие сервис будут иметь те же привилегии.

Возможные улучшения и обращение к читателю

В принципе, можно не останавливаться на достигнутом, и реализовать inetd под Windows в качестве сервиса, а также дополнить его возможностью запуска разных типов сетевых сервисов в зависимости от порта соединения. Если вы – специалист в сетевом программировании, буду рад конструктивной критике и советам, если же только начинаете – буду рад, если статья вам пригодилась (или хотя бы понравилась).


Эта статья опубликована в журнале RSDN Magazine #2-2005. Информацию о журнале можно найти здесь
    Сообщений 5    Оценка 210 [+1/-0]         Оценить