На написание данной статьи меня побудило прочтение аналогичного содержания статьи пользователя nkusnetsov. По количеству просмотров видно, что сообществу интересна данная тема.
Поэтому я решил поделиться с вами собственным решением, которое было ранее реализовано мной и обладает:
- Низким уровнем вхождения и простотой кода (для понимания/отладки другим сотрудником)
- Простые скрипты ROS не создают никакой нагрузки и работают даже на hAP Lite
- Масштабируемость – возможность подключения большого количества VPN-шлюзов с целью снижения нагрузки или географического распределения
- Возможность использования Mikrotik CHR в качестве VPN-сервера
- «1хN» – 1 SMS-шлюз на неограниченное количество роутеров с возможностью расширения при росте нагрузки
- Возможность привязки отдельного роутера к «конкретному» модему (для чего? – об этом позже)
- Использование всего одного php скрипта на удаленном сервере
- Не важно какое устройство инициировало VPN-соединение, авторизация по ссылке из SMS
- Ведение log'а всех авторизаций на сервере (можно вкл/выкл)
- Увеличение отказоустойчивости и снижение нагрузки системы путем отправки SMS рандомно с нескольких модемов
- Возможность отправки SMS через платные SMS-шлюзы (в коде на примере smsc.ru)
- Функция Firewall – доступ только у роутеров занесенных в список (можно вкл/выкл)
- Новое: Отправка кодов через Synology Chat и Telegram Bot. Причем различным пользователям уведомления могут доставляться по различным каналам.
Если вас интересует данная тематика, сохраните статью в закладки, я буду периодически её обновлять, по мере расширения функционала скрипта.
UPD 1.0 – Код php обновлен. Теперь он включает логирование всех запросов к скрипту (можно вкл/выкл) и рандомную отправку сообщений через несколько шлюзов в случае указания больше одного.
UPD 1.1 – Проект выложен в репозиторий на GitHub. По отзыву в комментарии добавлена возможность отправки через платные sms-шлюзы (с возможностью указания для каждого роутера откуда отправлять – модем или платно, можно рандомно сочетать несколько путей отправки для одного роутера).
UPD 1.2 – Добавлена функция firewall – доступ к функции генерации и отправки кодов авторизации есть только у роутеров занесенных в список (можно вкл/выкл). Все ответы заменены на базовые коды ответов HTTP
UPD 1.3 – Отправка сообщений с кодом пользователям в Synology Chat и через Telegram Bot. Причем различным пользователям уведомления могут доставляться по различным каналам. Также упрощены и обновлены скрипты для MT
Внимание! Актуальный код скриптов для MikroTik, а также php можно получить на github – ссылка в конце. В статье приведен изначальный код, который не теряет актуальность, т.к описывает общую логику действий.
Постановка задачи и решение
Задача 1
В процессе разработки данного решения была поставлена задача минимизировать количество usb-модемов – уменьшив стоимость владения, упростить администрирование, локализовать модемы в одном месте и тем самым улучшить ремонтопригодность системы.
Было решено использовать один роутер установленный на «надежном канале» как базовый SMS-шлюз. Единственное я не знал какова будет нагрузка на систему, справятся модемы и не заблокирует ли оператор. Поэтому я заложил возможность подключения нескольких модемов и дальнейшего разбиения системы на группы. Причем дополнительные модемы могут быть подключены к базовому шлюзу через простой usb-hub. Но для полноценного функционирования, в минимальном функционале, нужен один модем в не зависимости от количества роутеров.
Задача 2
Предусмотреть возможность использования локальных сим-карт в зоне установки роутера.
Пример: широкая филиальная сеть с несколькими магазинами в Казахстане. Отправка sms-сообщения из РФ будет стоить достаточно дорого. Данное решение позволяет сотрудникам из РК получать sms с локального номера.
Но в процессе размышлений оказалось, что решение уже было найдено и реализовано в Задаче 1.
Задача 3
Авторизация туннеля с мобильного устройства без необходимости физически находиться на авторизуемом устройстве.
Цель – возможность авторизовывать не только пользовательские туннели, но и любые vpn-соединения: Mikrotik->Mikrotik, Сервер->Mikrotik и т.д При этом пользователю, ответственному за данные туннели, необходимо просто перейти по ссылке из SMS сообщения, в которой также отображается какой туннель хочет авторизоваться.
Для возможности авторизации таким способом появилась необходимость вынести скрипт на внешний ресурс – сервер, доступный из любой точки а также код авторизации использовать для целей маркировки адресного листа в firewall и последующей разблокировки.
Решение данной задачи уже однозначно подразумевало использование RouterOS API (или SSH). Победу одержало API, как наиболее простой вариант.
Логика
- Пользователь авторизуется с заранее добавленным логином и паролем
- VPN-шлюз заносит его ip в адресный лист ожидания и отправляет POST запрос на сервер
- Сервер получает данные, проверяет, формирует код-авторизации и отправляет на контактный номер SMS вида: «To autorize user 79001112233 connection open – http_://synome.ru/?ruid=vrG7yYMbZ6&auth=YU6zc»
- Пользователь переходит по ссылке с любого устройства
- Сервер делает запрос на роутер, проверяет наличие в адрес-листе записи с кодом авторизации в комментарии. Если запись найдена, удаляет ее, а пользователю отображает страницу удачной авторизации
Во всех остальных случаях, если что-то не прошло проверку пользователь получит Request error.
Настройка и код
Теперь перейдем от идейной части к настройке Mikrotik, коду и их описанию
Пояснение: указанные скрипты и php-код работоспособны при любом типе VPN-сервера. Выбор типа сервера (PPP/L2TP/SSTP), настройки профиля и туннеля – вы выбираете «на свой вкус», я же приведу общий пример быстрой настройки.
Добавляем на Mikrotik:
Firewall
/ip firewall address-list add address=10.10.0.1 list=Allow-list add address=8.8.8.8 list=Allow-list add address=synome.ru list=Allow-list
/ip firewall raw add action=drop chain=prerouting dst-address-list=!Allow-list src-address-list=VPN-blocked disabled=no
PPP Profile
Создаем профиль в котором устанавливаем idle-таймаут соединения. Время должно быть меньше чем указанное в следующем скрипте On Up, в противном случае, если авторизация не была произведена, то после удаления списка из address-list пользователь получит доступ на время равное разнице (idle-таймаут минус address-list timeout)
/ppp profile add dns-server=10.10.0.1 idle-timeout=59m local-address=10.10.1.1 name=2F-VPN use-compression=no use-encryption=no use-mpls=no
Далее на последней вкладке Script добавляем:
:global ruid "#ROUTERLOGIN#"; :global pass "#ROUTER_PASSWORD#"; :local userip [/ppp active get [find name=$user] address]; # if phone number stored in comment #:local userphone [/ppp secret get [find name=$user] comment]; # if phone number = username :local userphone $user; :local authkey [/tool fetch http-method=post http-data="ruid=$ruidlogin&pass=$ruidpass&tel=$userphone&remote-ip=$userip" url="http://synome.ru/" mode=http as-value output=user]; /ip firewall address-list remove [find address=$userip]; /ip firewall address-list add address=$userip list=VPN-blocked timeout=1h comment=($authkey->"data"); :log info message="User connect:"; :log info message=$userphone; :log info message=$userip; :log info message=($authkey->"data");
:global ruid "#ROUTERLOGIN#"; :global pass "#ROUTER_PASSWORD#"; :local userip $"remote-address"; # if phone number stored in comment :local userphone [/ppp secret get [find name=$user] comment]; # if phone number = username #:local userphone $user; /tool fetch http-method=post http-data="ruid=$ruidlogin&pass=$ruidpass&tel=$userphone&action=down&remote-ip=$userip" url="http://synome.ru/" mode=http as-value output=user; /ip firewall address-list remove [find address=$userip]; :log info message="User disconnect:"; :log info message=$user; :log info message=$userip;
Смысл скриптов
При подключении: в самом начале мы задаем логин и пароль роутера, которые будут проверяться на сервере. При авторизации пользователя получаем его номер телефона (может быть в имени или в комментарии) и локальный ip-адрес. Отправляем POST-запрос на сервер и получаем в ответе код авторизации. Сервер записывает в лог данные поступившего запроса. Добавляем ip-адрес в address-list VPN-blocked с кодом авторизации в комментарии и тайм-аутом на 1 минуту больше чем в профиле. Выводим все в лог.
При отключении: получаем ip-адрес пользователя и его номер телефона, отправляем POST-запрос на сервер для сохранения в лог авторизации, находим его в address-list и удаляем. Все выводим в лог.
PPP Secrets
/ppp secret add comment="70001112233" name=70001112233 password=testuser profile=2F-VPN remote-address=10.10.1.100
Номер телефона можно указать прямо в name, но если хотим иметь возможность задавать один номер на несколько аккаунтов (для авторизации нескольких туннелей), то номер указываем в комментарии, при этом в скрипте On Up нужно изменить закомментированность строк
# if phone number stored in comment :local userphone [/ppp secret get [find name=$user] comment]; # if phone number = username #:local userphone $user;
Ну и самое главное включаем PPTP или L2TP сервер.
На этом с Mikrotik работа закончена.
Серверная часть на PHP
Ниже приведен код. Он достаточно подробно комментирован, поэтому не буду писать лишний текст. Самое главное – заменить данные в $host и $ruid_data на свои.
Полный проект на GitHub
<?php // ------------------------------------------------------------------------------ // Copyright (с) 2020 // Author: Dmitri Agababaev, d.agababaev@duncat.net // // Copyright by authors for used RouterOS PHP API class in the source code files // // Redistributions and use of source code, with or without modification, are // permitted that retain the above copyright notice // // License: MIT // ------------------------------------------------------------------------------ require_once('routeros_api.class.php'); // https://github.com/BenMenking/routeros-api // ------------------------- // Базовые настройки скрипта // Base settings // ------------------------- // Firewall - разрешает доступ к отправке SMS только роутерам из массива $ruid_data | Firewall - allow access to send SMS only for router from array $ruid_data $firewall = true; // Используем firewall? | Use firewall? true | false $uselog = true; // Используем лог? | Use log? true | false $log_path = 'mt2Fvpn.log'; // Путь к файлу лога | Log file path $host = 'https://yourwebsite.com/'; // Адрес по которому доступен данный скрипт | Full address that this script awalible // Массив данных всех роутеров/vpn-шлюзов | Routers data array used as vpn-servers $ruid_data = array( // пароль в md5, глобальный ip-адрес, логин входа на роутер, пароль, SMS-шлюз через который происходит отправка SMS // password in md5, global ip-address, mikrotik login, password, SMS-gateway-key will be use to send sms '#ROUTERLOGIN#' => array('mdpass' => '#ROUTER_MD5_PASSWORD#', 'ip' => 'XXX.XXX.X.X', 'login' => '#ROSAPI_LOGIN#', 'password' => '#PASSWORD#', 'smsgw' => array( // Если необходимо отправлять SMS рандомно с нескольких шлюзов (снижение нагрузки на модем) // If you want send SMS randomly from any SMS-gateways, add one here (low modem load) 0 => 'SMS_gw1') ) ); // Массив данных роутеров используемых в качестве sms-шлюзов | Routers data array will be used to send autherization sms $SMS_gateway = array( // ip-адрес шлюза (глобальный или локальный если в одной сети с сервером), логин, пароль, порт USB-модема, канал USB-модема // ip-address (global or local if used in one local network with server), login, password, USB-modem port, USB-modem channel 'SMS_gw1' => array('ip' => 'XXX.XXX.X.X', 'login' => '#ROSAPI_LOGIN#', 'password' => '#PASSWORD#', 'port' => '#USB_PORT#', 'channel' => '#USB_CHANNEL#') ); // ------------------------- // Входные проверки запросов // Input data check // ------------------------- if (!$_REQUEST) die(header('HTTP/1.0 406 Not Acceptable')); // если запроса нет – сброс | if request free - reset if (!$_REQUEST['ruid']) die(header('HTTP/1.0 406 Not Acceptable')); // если не указан ruid - сбросс | if ruid not isset – reset if (!array_key_exists($_REQUEST['ruid'], $ruid_data)) die(header('HTTP/1.0 406 Not Acceptable')); // если роутер не существует – сброс | if router does not exist – reset if ($_REQUEST['auth']) autorize(); // если запрос на авторизацию, то пускаем без пароля и проверяем авторизацию | if auth request allow without password if (!ruid_auth()) die(header('HTTP/1.0 401 Unauthorized')); // проверяем пароль роутера для отправки SMS | check ruid password if (@$_REQUEST['action'] == 'down') { // Если vpn-соединение закрыто | if vpn-connection closed writelog('CONNECTION CLOSED'); die(header('HTTP/1.0 200 ОК')); } if ($_REQUEST['tel']) send_authcode(); // если задан номер телефона, отправляем SMS | if phone number isset – sending SMS // --------------------------------------------------------------------- // Проверка на наличие роутера в списке разрешенных и пароля авторизации // Check for router (ruid) is in allowed list // --------------------------------------------------------------------- function ruid_auth() { global $ruid_data; if (!$_REQUEST['pass']) return false; // если пароль не задан – сброс | if password not set - reset // проверяем md5-хэш пароля | check password md5-hash if (md5($_REQUEST['pass']) == $ruid_data[$_REQUEST['ruid']]['mdpass']) return true; return false; } // --------------------------------------------------------- // Функция отправки ссылки с кодом авторизации через ros api // Send autherization sms via ros api function // --------------------------------------------------------- function send_authcode() { global $ruid_data; global $host; global $firewall; // Если firewall == true, то разрешаем доступ к отправке только роутрам внесенным в массив $ruid_data // If firewall == true, allow access only to routers has record in array $ruid_data if ($firewall) firewall(); // Выбираем шлюз рандомно | Get random SMS-gateway $sms_gw = $ruid_data[$_REQUEST['ruid']]['smsgw'][array_rand($ruid_data[$_REQUEST['ruid']]['smsgw'], 1)]; // данные sms-шлюза // генерируем код авторизации и добавляем его в массив | Generate auth-code and add to REQUEST array $_REQUEST['authcode'] = substr(str_shuffle('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789'), 0, 5); // Формируем сообщение отправляемое пользователю – только eng или транслит // Create message that will be sent to user $message = rawurlencode('To authorize user '.$_REQUEST['tel'].' connection open '.$host.'?ruid='.$_REQUEST['ruid'].'&auth='.$_REQUEST['authcode']); // Если используем платный sms шлюз | If use paid sms center gateway if ($sms_gw == "PAY") UsePAYsmsc($message); // подключаем класс | connect class $API = new RouterosAPI(); // если подключились отправляем SMS | if connected successfully - sending message if ($API->connect($SMS_gateway[$sms_gw]['ip'], $SMS_gateway[$sms_gw]['login'], $SMS_gateway[$sms_gw]['password'])) { // Команда отправки SMS | SMS send command $ARRAY = $API->comm("/tool/sms/send", array( "port"=>$SMS_gateway[$sms_gw]['port'], "channel"=>$SMS_gateway[$sms_gw]['channel'], "phone-number"=>$_REQUEST['tel'], "message"=>"To autorize user ".$_REQUEST['tel']." connection open – ".$host."?ruid=".$_REQUEST['ruid']."&auth=".$_REQUEST['authcode'],)); // если отправка не удалась и получили ошибку модема, то выполняем сброс питания usb для перезагрузки модема // Checking if send failed and error message return, will make usb power-reset to restart modem if($ARRAY['!trap']) { $API->comm("/system/routerboard/usb/power-reset"); // логируем запросы | save log // добавляем в массив сообщение об ошибке | Add error to array and save all array to log $_REQUEST['ERROR'] = $ARRAY['!trap'][0]['message']; writelog('MODEM ERROR'); die('Stop with error: '.$ARRAY['!trap'][0]['message'].' Making power reset of usb-port');} } $API->disconnect(); // логируем запросы | save log writelog('SEND AUTH CODE'); die($_REQUEST['authcode']); } // -------------------------------------------------------------------- // Функция авторизации через ros api – удаление из address-list // Autherization using ros api function – delete list from address-list // -------------------------------------------------------------------- function autorize() { global $ruid_data; // подключаем класс | connect class $API = new RouterosAPI(); if ($API->connect($ruid_data[$_REQUEST['ruid']]['ip'], $ruid_data[$_REQUEST['ruid']]['login'], $ruid_data[$_REQUEST['ruid']]['password'])) { // если подключились отправляем команду | if connected successfully - sending command $API->write('/ip/firewall/address-list/print', false); $API->write('?comment='.$_REQUEST['auth'], false); $API->write('=.proplist=.id'); // получаем ответ | get response $ARRAYS = $API->read(); // Если запись не существует в адрес-листе - сброс // If no record in firewall address_list – reset if (!$ARRAYS[0]) die(header('HTTP/1.0 406 Not Acceptable')); // удаляем запись | delete firewall address-list $API->write('/ip/firewall/address-list/remove', false); $API->write('=.id=' . $ARRAYS[0]['.id']); $READ = $API->read(); } $API->disconnect(); // логируем запросы | save log writelog('AUTHERIZATION'); // Информируем пользователя об успешной авторизации | Show success page to user die(' <!DOCTYPE html> <html lang="ru"> <meta http-equiv="Content-Type" content="charset=utf-8" /> <body style="font-family: Verdana, Arial, Helvetica, sans-serif; background-color: #282c34; color: #fff; height: 100vh; display: flex;"> <div style="margin: auto; max-width: 50%;"> <p style="font-size: 24pt; font-weight: bold; margin: 0 0 10px;"> VPN-соединение установлено, можете продолжить работу<br /> </p> <p style="font-size: 12pt; color: #aaa;"> В случае недоступности сервисов обратитесь к вашему системному администратору<br /> </p> <p style="font-size: 24pt; font-weight: bold; margin: 100px 0 10px;"> VPN connection is established, you can continue to work </p> <p style="font-size: 12pt; color: #aaa;"> If any services unavalible you must contact with your system administrator<br /> </p> </div> </body> </html> '); } // --------------------------------------------------------------------------------------------------------------------- // Отправка sms через платный сервис отправки сообщений (значение smsgw в $ruid_data должно быть установлено 0 => «pay») // Send sms via paid sms center gateway (smsgw value in $ruid_data should be installed 0 => «pay») // --------------------------------------------------------------------------------------------------------------------- function UsePAYsmsc($message) { // для примера использован smsc.ru | for example i use smsc.ru $smsc_login = '#SMSCLOGIN#'; $smsc_pass = '#SMSCPASSWORD#'; $smsc_sendername = '#SMSCSENDERNAME#'; // если используется | if need // Отправляем SMS | SEND SMS $sms_send = file_get_contents('https://smsc.ru/sys/send.php?login='.$smsc_login.'&psw='.$smsc_pass.'&phones='.$_REQUEST['tel'].'&mes='.$message.'&sender='.$smsc_sendername.'&flash=0'); if (strpos($sms_send, 'OK') !== false) { die($_REQUEST['authcode']); } die($_REQUEST['Send SMS error']); }; // -------------------------------------------------------------------------------- // Firewall - разрешает доступ к отправке SMS только роутерам из массива $ruid_data // Firewall - allow access to send SMS only for router from array $ruid_data // -------------------------------------------------------------------------------- function firewall() { global $uselog; global $log_path; global $ruid_data; $result = false; // перебираем массив и ищем ip в $ruid_data | serch ip in array $ruid_data foreach ($ruid_data as $value) { $result = (in_array($_SERVER['REMOTE_ADDR'], $value) === true) ? true : false; if($result) break; // если нашли совпадение, прерываем | if found record – break } // $uselog == true сохраняем лог | save log if (!$result) { if ($uselog) { $fp = fopen($log_path, 'a'); fputs($fp, "\nTime = " . date("Y-m-d H:i:s")."\n"); fputs($fp, 'FIREWALL BLOCKED ACCESS'."\n"); fputs($fp, $_SERVER['REMOTE_ADDR']."\n"); fclose($fp); } die(header('HTTP/1.0 403 Forbidden')); } } // ------------------------ // Функция сохранения логов // Save log function // ------------------------ function writelog($type) { global $uselog; global $log_path; // $uselog == false – break if (!$uselog) return; // удаляем пароль из массива перед сохранением лога | remove ruid password from array before saving log unset($_REQUEST['pass']); // сохраняем лог | save log $fp = fopen($log_path, 'a'); fputs($fp, "\nTime = " . date("Y-m-d H:i:s")."\n"); fputs($fp, $type."\n"); fputs($fp, print_r($_REQUEST, true)); fclose($fp); } ?>
RouterOS API class PHP используемый в коде можно взять на GitHub.
Благодарю за внимание. Буду рад любым комментариям.

