Full-stack приложение на javascript

Было на моем веку «парочка» историй о том, как нужно подключиться к 100500 компов холдинга и что-то поправить. И если вы работаете в компании где не 10 ПК, то это превращается в реальный геморрой.
В один из таких геморроев я зарекся, что решу эту проблему. К тому же я с головой ударился в javascript. С головой это когда первые пару месяцев вас тошнит от асинхронного кода, а потом уже вы начинаете тупить в синхронном коде php/bash/cmd.
В общем решил я написать приложение, чтобы можно было просто выполнить команду на удаленных ПК массово. Потом подумал и решил, что это нельзя будет потом в портфолио вставить и решил написать целую систему удаленного управления.
Итак, задачи (разделю их на 3 части): клиентское приложение, серверное приложение и панель администрирования.

Задачи.

Клиентская часть:

  • должна потреблять мало памяти (решено, потребление порядка 20МБ)
  • должна уметь выполнять базовые задачи для удаленного управления:
    • скачивать файлы (решено, библиотека download-file/позже пришлось её значительно переписать)
    • запускать скрипт (решено, библиотека child_process)
    • запускать команду в оболочке ОС (решено, библиотека child_process)
  • должна постоянно быть он-лайн (решено, по средствам websocket/библиотека socket.io)
  • должна уметь отчитываться на сервер о выполненных задачах (вывод из консоли ОС)

Исходя из моего опыта системного администрирования, удаленного управление ПК пользователей и серверами — этих базовых задач для начала должно быть достаточно и они позволяют пользователю работать одновременно с множеством удаленных машин.

 

Серверная часть:

  • должна создавать и поддерживать постоянные соединения с клиентскими приложениями (решено, по средствам websocket/библиотека socket.io)
  • должна проверять авторизацию клиентских приложений (решено, путем реализации проверки логина и пароля событиями в socket.io)
  • должна блокировать попытки брутфорса паролей (решено, путем реализации собственной ip2ban функции)
  • должна уметь создавать административные и пользовательские пары (решено)
  • должна уметь генерировать три вида задач, описанных в клиенте (решено)
  • должна создавать простой веб-сервер для вывода панели администрирования (решено, путем использования http, https).
  • должна разделять подключения на 2 типа (решено, путем проверки логина и пароля):
    • административные
    • клиентские
  • должна безопасно хранить пароли (решено, путем использования SHA256 с солью), у каждого клиента своя пара логин-пароль
  • должна генерировать группы пользователей (решено, группа генерируется путем префикса до точки в логине)
  • должна генерировать отчеты по выполненным заданиям (решено)
  • чуть позже оказалось, что необходимо переиндексировать (сортировать по ключу и по ключу вложенных объектов) объекты в хранилище (решено, путем реализации собственной функции переиндексации)
  • чуть позже захотелось передавать файлы клиентам (решено путем написания веб-сервера http/https с Basic авторизацией, подключенного к общей ip2ban функции. клиенту же ставится задача скачать файл, тут потребовалось доработать библиотеку download-file для использования авторизации)

Панель администрирования:

  • должна реализовать интерфейс для создания административного подключения к сокету (решено, путем авторизации в socket.io)
  • должна реализовать интерфейс для контроля за онлайном (решено, онлайн пользователей отображается в отдельном пункте меню, а также в нижнем правом углу панели отображается полный онлайн, включая администраторов)
  • должна реализовать основной функционал сервера (решено)
    • создание заданий клиентским приложениям
    • добавление и удаление пользователей
    • добавление и удаление администраторов
  • должна быть достаточно динамичной (решено, путем использования react + redux)

Система хранения данных.

Клиентская часть:

Данные хранятся в redux, при изменении данных в хранилище redux запускается функция с таймером 15 секунд. Сама функция делает снимок хранилища в момент запуска (т.е. через 15 сек) и пишет его в файл storage.db(по сути json).

При старте клиентского приложения данные загружаются из данного хранилища.

Из сокета клиентское приложение получает задачи для текущего пользователя. Они не перезаписывают собственное хранилище, а лишь добавляют те, что не были найдены. Также тут есть лайфхак, если на клиенте у задачи статус выполнения «не выполнено», а она пришла с статусом «выполнена» задача перезапишет статус на «выполнена» и выполняться не будет. В сокет же клиент отправляет статус выполнения и отчет по задаче. Т.е. серверу удалить задачу на клиенте нельзя, это сделано по причине того, что сервер не знает по сути выполнил ли клиент задачу до того момента, как получит отчет.

Чтобы не раздувать хранилище, реализован сборщик мусора. Он уничтожает задачи старше 10 дней, если у них нет отсрочки выполнения и задачи срок отсрочки выполнения которых старше 10 дней. Запускается раз в час.  Это чревато тем, что задачи с отсрочкой в более чем 10 дней на клиенте могут быть выполнены в течении часа после появления клиента онлайн, после чего будут уничтожены в хранилище клиента. Т.е. клиент выполнит и отчитается, но хранить их не будет.

Серверная часть:

Данные хранятся в 2-х хранилищах redux (serverStorage и connectionStorage).

При изменении данных в хранилище serverStorage запускается функция с таймером 60 секунд. Сама функция делает снимок хранилища в момент запуска (т.е. через 60 сек) и пишет его в firebase. Также запускаются функции генерации отчетов, генерации групп и функция выброса хранилища в административные сокеты (если они были подключены).

При старте сервера данные загружаются в serverStorage из firebase. Лайфхак, можно остановить сервер — изменить данные в firebase и запустить сервер (он загрузит новые данные). Лайфхак 2, если удалить всех администраторов — при старте автоматически будет создан администратор: administrator с паролем 12345678.

В хранилище serverStorage хранятся данные задач с актуальными статусами выполнения и отчетами, пары логин и хэш пароля пользователей и администраторов.

При получении нового задания из административного сокета — оно добавляется в данное хранилище (а также генерируется событие отправки задачи пользователю. Т.е. оно подписано на получение задачи из административного сокета, а не изменении хранилища. Т.е. клиент получает задачи при соединении с сервером и в момент получения задачи из административного сокета сервером).

При получении отчета о выполнении задача в хранилище обновляется (статус, отчет и кол-во попыток выполнения).

Хранилище connectionStorage хранит пары логин — идентификатор сокета, ip заблокированные ip2ban функцией(количество попыток неверной авторизации, если 5 — то блокировка) и время блокировки, отчеты по задачам и группы.

Хранилище connectionStorage динамическое и хранится только в памяти.

Оно обновляется при удалении пользователей(удаляется связка логин — идентификатор сокета). Также оно обновляется функциями генерация отчетов и генерация групп пользователей. В него же загружается порт файлового сервера при старте (для проброса в панель администрирования)

При изменении connectionStorage выбрасывается в сокеты администраторов, если они были подключены.

Также реализован сборщик мусора, который уничтожает выполненные задачи старше 10 дней, а также все остальные старше 100 дней, уничтожает данные из ip2ban по истечении срока блокировки.

Панель администратора:

Данные хранятся в 3-х хранилищах redux (serverStorage и connectionStorage, adminpanelStorage).

Первые два хранилища изменяются только по средствам получания их из сокета. Т.е. по сути дублируют хранилища сервера в режиме реального времени.

Хранилище adminpanelStorage необходимо для функционирования панели администратора. На данный момент хранит данные авторизации и всплывающего сообщения.

Особенности разработки.

Клиентская часть:

Изначально вроде бы ничего сложного не было были периодически проблемы со связью написал функцию, которая при дисконнекте восстанавливает соединение. Все вроде работало, однако потом я ввел систему ip2ban и использовал disconnect() на сервере и клиент в случае неверных учетных данных автоматом улетал в бан т.к. постоянно пересоединялся с неверными данными для авторизации. Тогда я написал функцию, которая при дисконнекте запускалась с таймером в 5 минут. Это решило проблему. Вроде бы, но тут вылезли фишки socket.io. Он сам восстанавливает соединение при дисконнекте. Т.о. если сервер был перезапущен в момент соединения с клиентом, то последний сам соединялся с сервером и запускал функцию Reconnect через 5 минут. Через 5 минут было тоже самое и так в бесконечном цикле. все работало, но эти реконнекты каждые 5 минут… В общем, спасибо что в даташите не писали о этой умной штуке socket.io — я неделю потратил (в свободное время) чтобы выщемить этот глюк, т.к. он был очень похож просто на разрыв связи и никаких ошибок не показывал. Я уже чуть было не стал грешить на стабильность websocket. Зато благодаря ему я отлично разобрался в способах передачи данных polling, long-polling, sse, comet, websocket.

Следующая проблема была в архитектуре управления задачами. При получении задач от сервера мне пришлось контроллировать, что данные задачи присутствуют или отсутствуют в хранилище. А при старте выполнения задач (runTask каждые 15 сек) контроллировать что задача уже не выполняется (та же скачка файла может быть больше 15 сек и запуститься еще несколько раз пока не выполнится). Собственно проблема была в том, что у меня существует больше чем 1 попытка выполнить задачу при ошибке (я поставил 100 попыток). Решил добавив в хранилище данных статус выполнения задачи. Но данные в хранилище обновляются раз в 15 секунд, а если задача за эти 15 секунд убила приложение (данные не успели обновиться), то оно при старте опять умрет от этой задачи и так в бесконечном цикле. Решил записью временных файлов при старте задачи и удалением при ошибке/выполнении. При этом, при старте клиентского приложения если временный файл задачи присутствует, то ей установится статус выполнено и количество попыток 100 (ошибка), файл же будет удален. Т.о. задача, которая приводит к краху приложения снимается (на данный момент позволяет например в systemd linux выполнить перезапуск службы командой «systemctl restart iocommander-client» и не уйти в бесконечный цикл перезапуска). Далее мне пришлось контроллировать статусы задач, при получении их от сервера. Если задача на клиенте в статусе выполнена, а от сервера приходит в статусе «не выполнена» — серверу отправляется статус «выполнена» и отчет о выполнении.

Следующая проблема была в том, что библиотека download-file не использовала авторизацию, а хранить передаваемые файлы в открытом доступе (а там могут быть и пароли или еще что в скриптах) было бы совсем глупо. Пришлось добавить в нее проверку домена скачки и если он соответствует домену сокета — использовать данные пользователя (логин и хэш пароля) для Basic авторизации.

Следующая проблема была в том что файловый сервер умирал на больших количествах соединений (100МБ х167 параллельных скачек потребляло всю оперативку и swap, после чего systemctl делала killed моему приложению). Файловый сервер был переписан на потоковый режим. Но в потоковой передачи нужно контроллировать целостность файла или потока. Тут есть проблема, посчитать md5 сумму файла, без того чтобы загрузить его в память (или без помощи внешних библиотек) нельзя. С другой стороны считать суммы при заливке файлов, потом хранить это где-то — не входило в мои планы. Однако я мог вытащить размер файла в байтах функцией fs.stat без необходимости грузить его в память. Т.о. на сервере я соединял поток чтения с ответом http, а в header (Content-length по стандарту) передавал размер потока. В библиотеке же download-file в случае завершения потока (она всегда считала что файл скачан) я проверял тем же fs.stat размер файла в байтах и если он не соответствовал числу переданному в content-length заголовке вызывал эксепшн с ошибкой file not full. Соответственно функция завершалась с ошибкой и была бы запущена повторно при следующей попытке (напомню, что у меня их 100).

Следующая проблема была с кодировкой windows консоли. На это я убил часов 6. Я уже написал в цикле перебор всех кодировок в три слоя, котроллировал биты в буфере, даже пропустил через файл. Что странно беру вывод консоли в файл (команда «> test.txt») открываю файл, он в OEM866. Читаю файл, конвертирую:

Все нормально. Делаю тоже самое с выводом консоли:

И получаю хрень… В общем путем диких пиздостраданий (простите за мой русский) я пришел к тому что:

  • в child_process.exec и child_process.execFile нужно явно указать кодировку {encoding:’cp866′} в случае windows
  • stdout, stderr, error в child_process.exec обрабатываю такой функцией:
function stdoutOEM866toUTF8(value){
	try {
		return iconv.decode(new Buffer(new Buffer(iconv.decode(value, 'cp866')), 'utf8'), 'utf8');
	} catch(e){
		return value;
	}
}

 

  • почему stdout в child_process.execFile не нужно ей обрабатывать — не знаю, предполагаю консоль винды слишком умная. Видимо раз скрипты в utf8, то и вывод она автоматом преобразует к Utf-8.

Есть и более простой вариант (весьма корявый), просто при старте приложения сделать chcp 65001|node src-user/iocommander-usr.js

Серверная часть:

Основные проблемы — вполне стандартные для сервера, это большое число запросов от клиентов, проблема использования ресурсов пк (оперативная память, процессор, место на диске) с интернетом проблемы не было, т.к. виртуалка моя в датацентре и там канал в 1Гбит с лимитом трафика в 2ТБ.
Первой моей ошибкой было отдать обработку статистических данных (группы пользователей и генерация отчетов в функционал панели администрирования). Это довольно ресурсоемкая операция и лучше её все же производить на предназначенном для этого сервере. Хотя это и позволило бы мне сэкономить (судя по тестам до 10МБ оперативной памяти на пике). Только вот если админов несколько, незачем каждому генерировать одни и те же данные. В общем тут я был очень рад что ядро панели администрирования и сервер на javascript и все в функциональном стиле… в общем я минут за 10 перекинул функционал на сервер, еще за полчаса обложил обработку ошибок и протестировал все.
Второй вопрос встал с тем что я все данные храню в объектах, а данные внутри них отсортированы в порядке добавления. Самый адекватный вариант решения этой проблемы создание нового объекта с отсортированными ключами. Я поискал библиотеки и не нашел ничего, что меня бы устроило. Они и весят много и мне надо было еще и сортировка на втором уровне вложенности (по ключам вложенных в объект объектов). Решил реализовать сам вот такой функцией:

function sortObjectFunc(ObjectForSort, KeyForSort, TypeKey, reverse){
	try{
		var SortObject = new Object,
			tempObject = new Object,
			tempArray = new Array,
			validaterone = 0,
			validatertwo = 0;
		
		for(var keyobject in ObjectForSort) { //проходим по всем ключам родителям объекта
			if(KeyForSort !== ''){
				if(typeof(ObjectForSort[keyobject][KeyForSort]) !== 'undefined'){ //проверяем что ключ потомок существует
					tempObject[ObjectForSort[keyobject][KeyForSort]] = keyobject; //создаем объект связку ключа потомка и ключа родителя
					tempArray.push(ObjectForSort[keyobject][KeyForSort]); //создаем массив ключей потомков
				}
			} else {
				tempArray.push(keyobject); //создаем массив ключей
			}
			validaterone++; //считаем число ключей объекта, чтобы потом сравнить с длинной массива
		}
		
		function sortNumber(a,b) { //сортируем массив в зависимости от переданного типа
			return a - b;
		}
		if(TypeKey === 'integer'){
			tempArray.sort(sortNumber);
		} else {
			tempArray.sort();
		}
		
		if(reverse){  //если задан параметр, то переворачиваем массив
			tempArray.reverse();
		}
		
		for(var i=0; i<tempArray.length; i++){ //проходим по отсортированному массиву ключей потомков
			if(KeyForSort !== ''){
				SortObject[tempObject[tempArray[i]]] = ObjectForSort[tempObject[tempArray[i]]]; //используем объект связку и старый объект, чтобы получить новый отсортированный объект
			} else {
				SortObject[tempArray[i]] = ObjectForSort[tempArray[i]];
			}
		}
		
		if(KeyForSort !== ''){ //учитываем, что для первого уровня валидация не нужна, т.к. не используется объект связка, где могли быть затерты одинаковые ключи
			for(var keyobject in SortObject){
				validatertwo++; //считаем число ключей нового объекта
			}
		}
		
		if((validaterone === validatertwo) || (KeyForSort === '')){ //если количество ключей не изменилось - выводим новый объект.
			return SortObject;
		} else {
			return ObjectForSort;
		}
	} catch(e){
		console.log(colors.red(datetime() + "Ошибка переиндексации ключей объекта!"));
		return ObjectForSort;
	}
}

 

Тут важно предусмотреть, что при сортировке по ключам вложенных объектов эти самые ключи в теории могут повторяться.

Расскажу про ip2ban, проблем тут не было и все просто, но все же: при ошибке аторизации в redux в массив пишется обект типа «отформатированный (помним что точки нельзя) IP»:{«число попыток»:»XX»,»время последней попытки»:»XX»}. При авторизации проверяется что IP адрес отсутствует в данном массиве, если он присутствует проверяется что число попыток меньше 5. Если число попыток =5 (ну или больше, что не возможно), то проверяется что время последней попытки меньше текущая дата минус таймаут блокировки, если условие не выполнилось — запрос отклоняется.

Вернемся к проблемам и начнем с частых запросов (на моих 160+ клиентских машинах при отправке задачи создается 167 выбросов событий в сокет), соответственно при выполнении задачи будет ровно столько же входящих данных. писать столько данных в firebase каждый раз глупо. Потому и была реализована запись с отсрочкой(см. структура хранения данных).
По такому же принципу реализована генерация групп пользователей и отчетов по задачам. По аналогичному принципу сделал и отправку данных в административный сокет (это, кстати позволило не забивать очередь сокета, а соответственно не создавать дикую утечку памяти).

Следующая глобальная проблема была с потреблением памяти файловым сервером. Он был переписан на потоковый режим (см. описание в клиентской части приложения).

Очередная проблема была в том, что запрос на другой порт сервера уже считается Google Chrome кроссдоменным и не позволяет использовать авторизацию. Сначала думал накостылить, а потом просто запихнул часть файлового сервера (получение файлов от администратора) в веб-сервер панели администрирования. Т.е. осталась как поддержка API, так и браузера (без костылей в виде передачи данных авторизации дополнительным запросом или заголовком).

Ну и последняя проблема была в том, как хранить эти самые файлы. Я решил сделать это так:

  • имя файла и разрешение хранится в задаче пользователю
  • на сервере файл лежит без разрешения и с именем = uid задачи
  • клиент при скачке меняет имя и разрешение на нормальные (из текста задачи)
  • сборщик мусора проверяет, что файлу соответствует минимум 1 задача, если нет — файл удаляется

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

Ну и постоянно пришлось жестко контроллировать расход оперативной памяти для чего я даже установил glances (после того как наигрался с process.memoryUsage() ).

Тесты : (Intel(R) Xeon(R) CPU L5640 @ 2.27GHz, 1 cores, 1GB RAM, 2GB SWAP, 20GB HDD, CentOS 7.4 x64):

  •   в пассивном режиме с онлайном в 170 ПК
    • 60-100МБ RAM
    • 0-1% CPU
  •  в активном режиме (отправка кучи заданий + скачка 100МБ файла всеми клиентами, нагрузка на порту ~350Mbit/sec) с онлайном в 170ПК
    • 200-300МБ RAM
    • 30-90% CPU

Реализация проекта.

Это слайд-шоу требует JavaScript.

 

Изначально я подразумевал создание клиентского и серверного приложения в качестве службы. С сервером проблем нет, тут что хочешь то и делай. С systemd тоже проблем нет: создаем службу:

[Unit]
Description=Web and Socket client for iocommander

[Service]
User=root
Group=root
WorkingDirectory=/farmin/iocom/
ExecStart=/bin/node /farmin/iocom/src-user/iocommander-usr.js
ExecStop=/bin/kill -9 $(pidof node)

[Install]
WantedBy=multi-user.target

И добавляем её в systemctl.

Общий вид скрипта установки:

#! /bin/bash 

echo
echo Убиваю процессы node и службу iocommander-client
kill -9 $(pidof node)
systemctl stop iocommander-client
systemctl disable iocommander-client

echo
while true; do
read -p "Установить node 8.1? :" yn
    case $yn in
        [Yy]* ) node=true; break;;
        [Nn]* ) node=false; break;;
        * ) echo "Пожалуйста ответьте y или n.";;
    esac
done

if [ $node == true ]
then
	rm -rf /bin/node
	MACHINE_TYPE=`uname -m`
	if [ $MACHINE_TYPE == 'x86_64' ]; then
		echo Устанавливаю node 8.1.0 x64.
		cp ./bin/x64/node /bin/node
		chmod 0755 /bin/node
	else
		echo Устанавливаю node 8.1.0 x86.
		cp ./bin/x86/node /bin/node
		chmod 0755 /bin/node
	fi
fi

sleep 5
echo Проверяю необходимые каталоги.
if ! [ -d /farmin/iocom/ ]; then
	echo Создаю директорию /farmin/iocom/
    mkdir -p /farmin/iocom/
else
	echo Очищаю директорию /farmin/iocom/
    rm -rf /farmin/iocom/*
fi

sleep 1
echo Копирую файлы скриптов.
tar xf src.tar -C /farmin/iocom/
chmod -R 0755 /farmin/iocom/*
chmod -R 0666 /farmin/iocom/src-user/iocommander-usr.conf

sleep 1
echo
echo Копирую демон внутреннего клиента.
rm -rf /etc/systemd/system/iocommander-client.service
cp ./iocommander-client.service /etc/systemd/system/iocommander-client.service
chmod 0755 /etc/systemd/system/iocommander-client.service
echo Перезагрузка демонов.
systemctl daemon-reload
echo Запускаем внутренний сервер.
systemctl restart iocommander-client
echo Добавляем демона в автозапуск.
systemctl enable iocommander-client

sleep 1
echo 
echo Установка клиента завершена.

И удаления:

#! /bin/bash 

echo
echo Убиваю процессы node и службу iocommander-client.
kill -9 $(pidof node)
systemctl stop iocommander-client
systemctl disable iocommander-client

echo Удаляю бинарник node.
rm -rf /bin/node

echo Удаляю папку с программой.
rm -rf /farmin/iocom/

echo Удаляю файл службы.
rm -rf /etc/systemd/system/iocommander-client.service

echo Перезагружаю системных демонов.
systemctl daemon-reload

echo Удаление клиента завершено.

 

Проблема, что на клиентских машинах на 80% оказалось отсутствие systemd (OS Xubunty/Ubunty 14.01). Пришлось покостылить и создать скрипт:

#!/bin/bash
cd /farmin/iocom/ && node /farmin/iocom/src-user/iocommander-usr.js

И добавить его в rc.local для автозагрузки.

В случае же windows было использовано стороннее ПО (NSSM) для создания службы. В общем виде скрипт установки выглядит так:

IF EXIST "%PROGRAMFILES(X86)%" (GOTO 64BIT) ELSE (GOTO 32BIT)

:64BIT
COPY bin\x64\node.exe C:\Windows\System32\node.exe /B
IF NOT EXIST "C:\Windows\System32\nssm.exe" (
	COPY bin\x64\nssm.exe C:\Windows\System32\nssm.exe /B
)
GOTO NEXT

:32BIT
COPY bin\x86\node.exe C:\Windows\System32\node.exe /B
IF NOT EXIST "C:\Windows\System32\nssm.exe" (
	COPY bin\x86\nssm.exe C:\Windows\System32\nssm.exe /B
)
GOTO NEXT

:NEXT
XCOPY src C:\farmin\iocom\ /E /R /Y
COPY iocommander-client.cmd C:\farmin\iocom\iocommander-client.cmd /A /Y
COPY restart.cmd C:\farmin\iocom\restart.cmd /A /Y
C:\Windows\System32\NSSM remove IOComClientSrv confirm
C:\Windows\System32\NSSM install IOComClientSrv "C:\farmin\iocom\iocommander-client.cmd"
C:\Windows\System32\NSSM set IOComClientSrv DisplayName IOComClientSrv
C:\Windows\System32\NSSM set IOComClientSrv Description Service for IOCommander Client win32 platform
C:\Windows\System32\NSSM set IOComClientSrv Start SERVICE_AUTO_START

Где iocommander-client.cmd это:

cd %~dp0
IF NOT EXIST "%~dp0logs\" (
	MKDIR %~dp0logs
)
node %~dp0src-user\iocommander-usr.js >> %~dp0logs\%date%.log

Скрипт удаления:

ECHO Убиваю службу iocommander-client.
NSSM remove IOComClientSrv confirm

ECHO Удаляю папку с программой.
ERASE /Q C:\farmin\iocom\

ECHO Удаляю бинарники Node и Nssm.
ERASE /Q C:\Windows\System32\nssm.exe
ERASE /Q C:\Windows\System32\node.exe

ECHO Удаление клиента завершено.

Рестарт службы:

:stop
SC stop IOComClientSrv

rem cause a ~10 second sleep before checking the service state
PING 127.0.0.1 -n 10 -w 1000 > NUL

SC query IOComClientSrv | FIND /I "STATE" | FIND "STOPPED"
IF ERRORLEVEL 1 GOTO :stop
GOTO :start

:start
NET start | FIND /i "IOComClientSrv">NUL && GOTO :start
SC start IOComClientSrv

 

Исходники доступны на GITHUB Более свежая версия всегда в ветке master, стабильная в production.