Здравствуйте!

На написание данной статьи меня побудило прочтение аналогичного содержания статьи пользователя nkusnetsov. По количеству просмотров видно, что сообществу интересна данная тема.

Поэтому я решил поделиться с вами собственным решением, которое было ранее реализовано мной и обладает:

  • Низким уровнем вхождения и простотой кода (для понимания/отладки другим сотрудником)
  • Простые скрипты ROS не создают никакой нагрузки и работают даже на hAP Lite
  • Масштабируемость – возможность подключения большого количества VPN-шлюзов с целью снижения нагрузки или географического распределения
  • Возможность использования Mikrotik CHR в качестве VPN-сервера
  • «1хN» – 1 SMS-шлюз на неограниченное количество роутеров с возможностью расширения при росте нагрузки
  • Возможность привязки отдельного роутера к «конкретному» модему (для чего? – об этом позже)
  • Использование всего одного php скрипта на удаленном сервере
  • Не важно какое устройство инициировало VPN-соединение, авторизация по ссылке из SMS
  • Новое: Ведение log'а всех авторизаций на сервере (можно вкл/выкл)
  • Новое: Увеличение отказоустойчивости и снижение нагрузки системы путем отправки SMS рандомно с нескольких модемов
  • Новое: Возможность отправки SMS через платные SMS-шлюзы (в коде на примере smsc.ru)
  • Новое: Функция Firewall – доступ только у роутеров занесенных в список (можно вкл/выкл)


Если вас интересует данная тематика, сохраните статью в закладки, я буду периодически её обновлять, по мере расширения функционала скрипта.


UPD 1.0 – Код php обновлен. Теперь он включает логирование всех запросов к скрипту (можно вкл/выкл) и рандомную отправку сообщений через несколько шлюзов в случае указания больше одного.

UPD 1.1 – Проект выложен в репозиторий на GitHub. По отзыву в комментарии добавлена возможность отправки через платные sms-шлюзы (с возможностью указания для каждого роутера откуда отправлять – модем или платно, можно рандомно сочетать несколько путей отправки для одного роутера).

UPD 1.2 – Добавлена функция firewall – доступ к функции генерации и отправки кодов авторизации есть только у роутеров занесенных в список (можно вкл/выкл). Все ответы заменены на базовые коды ответов HTTP

Постановка задачи и решение


Задача 1


В процессе разработки данного решения была поставлена задача минимизировать количество usb-модемов – уменьшив стоимость владения, упростить администрирование, локализовать модемы в одном месте и тем самым улучшить ремонтопригодность системы.

Было решено использовать один роутер установленный на «надежном канале» как базовый SMS-шлюз. Единственное я не знал какова будет нагрузка на систему, справятся модемы и не заблокирует ли оператор. Поэтому я заложил возможность подключения нескольких модемов и дальнейшего разбиения системы на группы. Причем дополнительные модемы могут быть подключены к базовому шлюзу через простой usb-hub. Но для полноценного функционирования, в минимальном функционале, нужен один модем в не зависимости от количества роутеров.

Задача 2


Предусмотреть возможность использования локальных сим-карт в зоне установки роутера.
Пример: широкая филиальная сеть с несколькими магазинами в Казахстане. Отправка sms-сообщения из РФ будет стоить достаточно дорого. Данное решение позволяет сотрудникам из РК получать sms с локального номера.

Но в процессе размышлений оказалось, что решение уже было найдено и реализовано в Задаче 1.

Задача 3


Авторизация туннеля с мобильного устройства без необходимости физически находиться на авторизуемом устройстве.

Цель – возможность авторизовывать не только пользовательские туннели, но и любые vpn-соединения: Mikrotik->Mikrotik, Сервер->Mikrotik и т.д При этом пользователю, ответственному за данные туннели, необходимо просто перейти по ссылке из SMS сообщения, в которой также отображается какой туннель хочет авторизоваться.

Для возможности авторизации таким способом появилась необходимость вынести скрипт на внешний ресурс – сервер, доступный из любой точки а также код авторизации использовать для целей маркировки адресного листа в firewall и последующей разблокировки.

Решение данной задачи уже однозначно подразумевало использование RouterOS API (или SSH). Победу одержало API, как наиболее простой вариант.

Логика


  1. Пользователь авторизуется с заранее добавленным логином и паролем
  2. VPN-шлюз заносит его ip в адресный лист ожидания и отправляет POST запрос на сервер
  3. Сервер получает данные, проверяет, формирует код-авторизации и отправляет на контактный номер SMS вида: «To autorize user 79001112233 connection open – http_://synome.ru/?ruid=vrG7yYMbZ6&auth=YU6zc»
  4. Пользователь переходит по ссылке с любого устройства
  5. Сервер делает запрос на роутер, проверяет наличие в адрес-листе записи с кодом авторизации в комментарии. Если запись найдена, удаляет ее, а пользователю отображает страницу удачной авторизации

Во всех остальных случаях, если что-то не прошло проверку пользователь получит Request error.

Настройка и код


Теперь перейдем от идейной части к настройке Mikrotik, коду и их описанию
Пояснение: указанные скрипты и php-код работоспособны при любом типе VPN-сервера. Выбор типа сервера (PPP/L2TP/SSTP), настройки профиля и туннеля – вы выбираете «на свой вкус», я же приведу общий пример быстрой настройки.

Добавляем на Mikrotik:


Firewall


Обязательно создаем список разрешенных ресурсов, среди которых: собственный адрес MT, любой публичный DNS, адрес сервера на котором будет происходить авторизация
/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


Добавляем правило блокирующее трафик VPN-пользователей из списка ожидания на любой хост кроме разрешенных
/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 добавляем:

On Up
: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");


On Down
: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

index.php
<?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.

Благодарю за внимание. Буду рад любым комментариям.