Как стать автором
Обновить

Расширение браузера для управления маршрутами на Микротике

Уровень сложностиСредний
Время на прочтение26 мин
Количество просмотров12K

Моя домашняя сеть состоит из нескольких хостов по стране и планете, три провайдера заходит на роутер – нормальная тренировочная площадка для получения новых знаний. Можно выйти в большую сеть из любого шлюза своей, для этого написана простая система правил – локальный адрес находящийся в определённом списке адресов будет маршрутизирован на заданный в правиле файрволла шлюз, либо, при обращении к домену находящемуся в определённом списке адресов файрволла маршрут к нему будет идти через заданный в правиле шлюз. Всё просто – хочешь побродить по большой сети другими маршрутами – перенёс свой локальный адрес в нужный список, хочешь, чтобы маршрут к сайту был всегда через определённый шлюз – внёс его домен или адрес в нужный список. Знаете, как мне надоело заходить в интерфейс маршрутизатора каждый раз, когда требуется внести адрес в список? Лень взяла верх и на днях заставила написать плагин для браузеров облегчающий эту работу.

Вот небольшое видео посмотреть на плагин и его работу:

Приложение совсем не энтерпрайз, скорее, админский скрипт с нормальным интерфейсом. Публикую сейчас, пока данная версия универсальна для любого Микротика и может кому-то оказаться полезной, дальше могу уйти в написание собственного API на своём web-сервере, и решение станет намного более тяжеловесным и сложным. Плагин для браузера писал первый раз, пользовался статьёй: https://habr.com/ru/articles/703330/, большое спасибо! Подключение расширения браузера описывать не буду, об этом подробно написано в указанной статье. Плагин работает в браузерах Chrome и Edge – для сёрфинга использую именно их, Mozilla для разработки, остальное для всякого. Возможно, позже добавлю поддержку других браузеров.

Приложение работает с роутерами MikroTik по REST API, для этого стоит создать отдельного пользователя на устройстве в группе с правами: read, write, api, rest-api, и правом авторизации с ограниченного списка хостов. Управление маршрутами для локального адреса происходит следующими правилами файрволла:

/ip firewall mangle chain=prerouting action=route passthrough=no route-dst=GATEWAY_ADDRESS src-address-list=routed-mos dst-address-list=!not-routed
/ip firewall mangle chain=prerouting action=mark-routing new-routing-mark=route-lte passthrough=no src-address-list=routed-lte dst-address-list=!not-routed

Когда локальный адрес находится в одном из описанных списков, его исходящий трафик маршрутится на указанный шлюз. Необходимо завести специальный список на файрволле немаршрутизируемых адресов, в него включить все локальные сети и внешние адреса всех своих хостов с которыми ваш роутер устанавливает соединения, чтобы исключить коллизии. Управление маршрутами для внешних ресурсов происходит такими правилами:

/ip firewall mangle chain=prerouting action=route passthrough=no route-dst=GATEWAY_ADDRESS dst-address-list=mos-domains

Когда обращаемся к хосту из заданного списка, трафик к нему пойдёт через указанный шлюз. Здесь есть много тонкостей, можно хранить адрес внешнего хоста, либо его доменное имя и система сама создаст список адресов откликающихся на это имя. Доменное имя в списке файрволла даёт нагрузку на устройство, порой система буквально каждую секунду опрашивает адреса хоста, на слабых платформах процессор с таким не справляется. Хороший пример такого хоста сайт – www.quora.com, и, наверное, любые другие соцсети, с коими не знаком, но пример подвернулся годный. Однако с такими ресурсами не сработает простое добавление их текущих адресов в список файрволла, они постоянно меняются, добавляются, тут либо смириться с нагрузкой на процессор и избирательно добавлять доменные имена в свои списки контролируя нагрузку, либо добавлять в списки пулы адресов замеченные за ресурсом.

Итак, собственно, плагин. Когда он установлен в панель инструментов, при вызове опрашивает у роутера текущий маршрут локального адреса и текущий маршрут до страницы на активной вкладке браузера, и даёт возможность изменить и то и другое одним нажатием. Информация об адресах берётся с моего API о котором уже писал, забава обрела пользу. Можете пользоваться приложением с этим API, я иногда читаю логи по хозяйски проверяя состояние хостинга и работоспособность бэка, больше вам это ничем не грозит. Не сложно переписать приложение на использование любого другого API.

До подключения расширения в браузер необходимо заполнить файл manifest.json – в массив "host_permissions" надо внести адрес своего роутера: "*://192.168.88.1/". В файле settings.js необходимо заполнить массивы "routes" и "domains" – ключи массива, это то, что отображается в списке выбора – произвольное наименование маршрута, значения – имена ваших списков адресов на файрволле. Пустое значение имени списка означает удаление текущих маршрутов для домена и отключение текущих маршрутов локального адреса – локальный адрес не удаляется из списка при переходе на основной маршрут, он становится неактивным. Если приложение обнаружит несколько копий локального адреса в перечисленных списках файрволла, все операции будут применимы к первому из них, остальные будут отключены. Если маршрутизируемый домен или его адрес отключен, приложение не будет его удалять при удалении его маршрута исходя из того, что вы видимо зачем-то специально его отключили.

Заполняем массивы "exclude" и "notrouted" – они суть одно и то же, адреса и подсети, которые приложение должно игнорировать и не назначать им маршруты. Разделены по сущностям, "notrouted" – все служебные сети, "exclude" – внешние адреса хостов сети, можно заполнять только один из этих массивов, на работоспособность это не повлияет. Ключи массивов – адреса подсетей (первый адрес диапазона образуемого подсетью), значения – значение префикса подсети. В приложении реализован поиск адреса по ключу ассоциативного массива – по сути, используем готовые хэши создаваемые платформой, очень производительное решение, но оценить его можно только на больших базах подсетей.

Остальные параметры можно не заполнять в файле, а заполнить их уже в приложении и они будут сохранены в локальном хранилище расширения браузера. Есть три способа хранить учетные данные пользователя API роутера: в открытом виде указать их в файле settings.js в полях "user" и "password" – можно, но лучше так не делать, пользоваться только в процессе отладки. Лучше оставить эти поля пустыми, внести имя и пароль в приложении, сохранить и они будут сохранены в локальном хранилище расширения в полях: "an" (имя) и "ap" (пароль), в слегка зашифрованном виде, будет спокойнее. А можно скопировать эти поля с зашифрованными значениями из локального хранилища и добавить их в файл settings.js вместе с "user" и "password" ("user" и "password" имеют приоритет для приложения, держите их пустыми, если не используете), но тогда надо очистить хранилище, оно имеет приоритет для полей "an" и "ap".

Как закончили с заполнением файла settings.js, подключаем расширение в браузер, включаем его на панель инструментов, запускаем и заполняем оставшуюся часть: локальный адрес используемый по умолчанию (ваша станция), добавлять домены в списки файрволла временно или нет, время жизни добавленного домена в список. Способ добавления домена в список: Top – только верхний домен, WWW – если верхний домен содержится в списке "www", в список файрволла будет так же добавлен следующий за ним домен, All – добавить в список все домены по второй уровень. Метод добавления домена: Domain – домен будет добавлен по имени, Address – по всем доступным адресам на домене – хороший вариант, но далеко не всегда работает, при богатом наборе адресов на хосте будет доступна только часть из них.

Раскрываем раздел Settings, заполняем адрес роутера, имя и пароль пользователя API, выбираем протокол, который вы настроили, заполняем список доменов верхнего уровня на свой вкус и жмём сохранить. После этого закроем и снова откроем плагин, теперь он готов к работе.

Когда добавляем локальный адрес в список с маршрутом, происходит удаление существующих подключений локального адреса с веб-портами API хоста и хоста страницы текущей вкладки браузера – это помогает быстрее произойти переключению маршрута, но не всегда всё происходит быстро, с этим моментом ещё надо разбираться, например, переход на Yota у меня происходит 4-5 секунд, как я не старался прибить все соединения. Однако на основной функционал это не влияет, переход между проводными соединениями происходит быстро.

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

Файл manifest.json
{
  "name": "MikroTik Control Panel",
  "description": "MikroTik Control Panel",
  "version": "1.0",
  "manifest_version": 3,
    "icons": {
      "16": "icons/logo16.png",
      "32": "icons/logo32.png",
      "48": "icons/logo48.png",
      "128": "icons/logo128.png"
  },
  "action": {
    "default_popup": "popup.html"
  },
  "permissions": [
    "scripting",
    "activeTab",
    "storage"
  ],
  "host_permissions": [
    "*://192.168.88.1/"
  ],
  "background": {}
}

Файл setting.js
const settings = {
  "localhost": "192.168.88.77",
  "router": "192.168.88.1",
  "protocol": "http",
  "user": "",
  "password": "",
  "defaults": {
    "dynamic": false,
    "time": "02:00:00",
    "www": "www,web,wap,m",
    "what": "www",
    "how": "name"
  },
  "routes": {
    "Default": "",
    "Yota": "routed-lte",
    "Rostelecom": "routed-dsl",
    "Saint Petersburg": "routed-spb",
    "Moscow": "routed-mos",
    "New York": "routed-usa",
    "Singapore": "routed-sin"
  },
  "domains": {
    "Default": "",
    "Moscow": "mos-domains",
    "New York": "usa-domains",
    "Singapore": "sin-domains"
  },
  "exclude": {
    "1.1.1.1": 32,
    "8.8.8.8": 32
  },
  "notrouted": {
    "0.0.0.0": 8,
    "10.0.0.0": 8,
    "100.64.0.0": 10,
    "127.0.0.0": 8,
    "169.254.0.0": 16,
    "172.16.0.0": 12,
    "192.168.0.0": 16,
    "192.0.0.0": 24,
    "192.0.2.0": 24,
    "192.88.99.0": 24,
    "198.18.0.0": 15,
    "198.51.100.0": 24,
    "203.0.113.0": 24,
    "224.0.0.0": 3
  }
}

Файл popup.html
<!DOCTYPE html>
<html>
<head>
<title>MikroTik control panel</title>
<link rel="stylesheet" type="text/css" href="popup.css"/>
</head>
<body>
  <p class="browntext" style="font-weight:bold;font-size:13px;">MikroTik Control Panel</p>
  <p class="errormessage" id="error-messages" style="font-weight:normal;font-size:13px;"></p>
  <p class="infomessage" id="current-address" style="margin-top:4px;font-weight:bold;font-size:14px;">-</p>
  <p class="infomessage" id="address-city" style="font-size:12px;">-</p>
  <p class="infomessage" id="address-provider" style="font-size:12px;">-</p>
  <div style="margin-top:8px;">
    <label for="local-address">Host:</label>
    <input type="text" id="local-address" value="" style="width:160px;margin-left:4px;text-align:left;" />
  </div>
  <div style="margin-top:10px;">
    <label for="route-selection">Route:</label>
    <select id="route-selection" style="width:152px;margin-left:5px;"></select>
  </div>
  <button id="route-button" class="formsbutton2">Apply</button>
  <p class="infomessage" id="current-page" style="font-weight:bold;font-size:14px;">-</p>
  <p class="infomessage" id="page-address" style="font-size:14px;">-</p>
  <p class="infomessage" id="page-city" style="font-size:12px;">-</p>
  <div style="margin-top:10px;">
    <label for="domain-selection">Route:</label>
    <select id="domain-selection" style="width:152px;margin-left:5px;"></select>
  </div>
  <div style="margin-top:11px;">
    <input type="checkbox" id="domain-dynamic"/>
    <label for="domain-dynamic">Dynamic:</label>
    <input type="text" id="domain-time" value="" style="width:112px;margin-left:4px;text-align:left;" />
  </div>
  <div style="margin-top:8px;">
    <input type="radio" id="domain-top" name="what-add" />
    <label for="domain-top">Top</label>
    <input type="radio" id="domain-www" name="what-add" style="margin-left:10px;" checked />
    <label for="domain-www">WWW</label>
    <input type="radio" id="domain-all" name="what-add" style="margin-left:10px;" />
    <label for="domain-all">All</label>
  </div>
  <div style="margin-top:8px;">
    <input type="radio" id="domain-name" name="how-add" checked />
    <label for="domain-name">Domain</label>
    <input type="radio" id="domain-address" name="how-add" style="margin-left:10px;" />
    <label for="domain-address">Address</label>
  </div>
  <button id="domain-button" class="formsbutton1">Apply</button>
  <button id="settings-button" class="headerbutton1">Settings...</button>
  <div id="settings-block" hidden>
    <div style="margin-top:10px;">
      <label for="router-address">Router:</label>
      <input type="text" id="router-address" value="" style="margin-left:4px;width:147px;text-align:left;" />
    </div>
    <div style="margin-top:10px;">
      <label for="router-user">User:</label>
      <input type="text" id="router-user" value="" style="margin-left:4px;width:160px;text-align:left;" />
    </div>
    <div style="margin-top:10px;">
      <label for="router-user">Password:</label>
      <input type="password" id="router-password" value="" style="margin-left:4px;width:130px;text-align:left;" />
    </div>
    <div style="margin-top:10px;">
      <label for="protocol-selection">Protocol:</label>
      <select id="protocol-selection" style="width:135px;margin-left:5px;">
        <option value="http">HTTP</option>
        <option value="https">HTTPS</option>
      </select>
    </div>
    <div style="margin-top:10px;">
      <label for="top-domains">WWW:</label>
      <input type="text" id="top-domains" value="" style="margin-left:4px;width:150px;text-align:left;" />
    </div>
    <button id="save-button" class="formsbutton1">Save settings</button>
    <p class="infomessage" id="settings-info" style="font-size:14px;font-style:italic;"></p>
  </div>
  <script src="settings.js"></script>
  <script src="popup.js"></script>
</body>
</html>

Файл popup.css
body {
  text-align: center;
}
label,
button {
  cursor: pointer;
  font-size: 14px;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  -khtml-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
label + input {
  font-size: 14px;
  margin-top: 0px;
  text-decoration: none;
  vertical-align: middle;
  display: inline-block;
}
input[type=checkbox],
input[type=radio] {
  width: 18px;
  height: 18px;
  cursor: pointer;
  border: 1px solid #ccc;
  box-sizing: border-box;
  vertical-align: middle;
  accent-color: #3586E5;
  margin-top: -2px;
  margin-bottom: 0;
  margin-left: 0;
  margin-right: 2px;
  padding: 0;
}
textarea,
input[type=text],
input[type=password] {
  font-size: 14px;
  padding: 5px;
  border: 1px solid #ccc;
  box-sizing: border-box;
  background-color: #fff;
  border-radius: 0;
}
select {
  font-size: 14px;
  font-weight: normal;
  text-align: center;
  padding: 5px;
  border: 1px solid #ccc;
  box-sizing: border-box;
  background-color: #fff;
  border-radius: 0;
}
button {
  margin-top: 10px;
  margin-bottom: 5px;
}
button:disabled {
  background-color: #D1D1D1;
}
.fileupload,
.formsbutton0,
.formsbutton1,
.formsbutton2 {
  font-size: 11pt;
  font-weight: normal;
  font-style: normal;
  padding: 6px;
  border: none;
  cursor: pointer;
  width: 200px;
  display: inline-block;
}
.fileupload:enabled:hover,
.formsbutton0:enabled:hover,
.formsbutton1:enabled:hover,
.formsbutton2:enabled:hover,
.linktext:hover {
  opacity: 0.8;
}
.fileupload:disabled,
.formsbutton0:disabled,
.formsbutton1:disabled,
.formsbutton2:disabled {
  background-color: #D1D1D1;
  cursor: default;
}
.formsbutton0 {
  background-color: #E54335;
  color: white;
}
.formsbutton1 {
  background-color: #43A047;
  color: white;
}
.fileupload,
.formsbutton2 {
  background-color: #3586E5;
  color: white;
}
.headerbutton1,
.headerbutton2 {
  cursor: pointer;
  background:none;
  border:none;
  margin:0;
  padding:0;
}
.headerbutton1 {
  font-size: 14px;
  font-weight: bold;
  color: #00695C;
}
.headerbutton2 {
  font-size: 12px;
  font-weight: normal;
  color: #004D40;
}
.headerbutton1:hover,
.headerbutton2:hover {
  text-decoration: underline;
  opacity: 0.8;
}
.textmessage,
.infomessage,
.errormessage,
.bluetext,
.violettext,
.yellowtext,
.browntext {
  margin: 0px;
  padding: 0px;
}

@media (prefers-color-scheme: light) {
body {
  
}
.headerbutton1 {
  color: #00695C;
}
.headerbutton2 {
  color: #004D40;
}

.errormessage {
  color: #D50000;
}
.bluetext {
  color: MediumBlue;
}
.violettext {
  color: DarkViolet;
}
.yellowtext {
  color: #987F08;
}
.browntext {
  color: #795548;
}
.infomessage,
.textmessage {
  color: black;
}
}

@media (prefers-color-scheme: dark) {
body {
  background: #111B23;
}
select,
textarea,
input[type=text],
input[type=password] {
  background: #142633;
  color: #CFD8DC;
}
select,
textarea,
input[type=text],
input[type=password],
input[type=checkbox],
input[type=radio] {
  border-color: #78909C;
}
input[type=checkbox]:not(:checked),
input[type=radio]:not(:checked) {
  opacity: 0.9;
}
.fileupload:focus,
.formsbutton0:focus,
.formsbutton1:focus,
.formsbutton2:focus,
input[type=checkbox]:focus,
input[type=radio]:focus,
select:focus,
textarea:focus,
input[type=text]:focus,
input[type=password]:focus {
  outline-color: #CFD8DC;
}
.appheader1,
.appheader2,
.infomessage,
.textmessage,
label, h5, th, td {
  color: #CFD8DC;
}
.errormessage {
  color: #F77878;
}
.linktext {
  color: #7986CB;
}
.linktext:focus {
  outline-color: #7986CB;
}
.linktext:visited {
  color: #BA68C8;
}
.linktext:visited:focus {
  outline-color: #BA68C8;
}
.headerbutton1 {
  color: #26A69A;
}
.headerbutton2 {
  color: #4DB6AC;
}
.headerbutton1:focus {
  outline-color: #26A69A;
}
.headerbutton1:focus {
  outline-color: #4DB6AC;
}
.fileupload:disabled,
.formsbutton0:disabled,
.formsbutton1:disabled,
.formsbutton2:disabled {
  background-color: #626567;
  color: #A6ACAF;
}
.fileupload:enabled,
.formsbutton0:enabled,
.formsbutton1:enabled,
.formsbutton2:enabled {
  color: #ECEFF1;
}
.bluetext {
  color: #64B5F6;
}
.violettext {
  color: #CE93D8;
}
.yellowtext {
  color: #E6EE9C;
}
.browntext {
  color: #A1887F;
}

}

Файл popup.js
let opened_tab = null;
let api_ip_address = null;
let api_ip_query = '';
let routes_query = '';
let domains_query = '';
let domains_regular = ',';
let exclude_minprefix = 32;
let exclude_maxprefix = 0;
let notrouted_minprefix = 32;
let notrouted_maxprefix = 0;

async function InitPage()
{
  let load_settings = null;
  await chrome.storage.local.get(["MikroTikControlPanelSettings"]).then((result) => {
    if (typeof result.MikroTikControlPanelSettings != 'undefined')
      load_settings = result.MikroTikControlPanelSettings;
  });
  
  if (load_settings !== null) {
    settings['user'] = DecodeString(load_settings['an']);
    settings['password'] = DecodeString(load_settings['ap']);
    settings['localhost'] = load_settings['localhost'];
    settings['router'] = load_settings['router'];
    settings['protocol'] = load_settings['protocol'];
    settings['defaults']['dynamic'] = load_settings['defaults']['dynamic'];
    settings['defaults']['time'] = load_settings['defaults']['time'];
    settings['defaults']['www'] = load_settings['defaults']['www'];
    settings['defaults']['what'] = load_settings['defaults']['what'];
    settings['defaults']['how'] = load_settings['defaults']['how'];
  } else {
    if ((typeof settings['user'] == 'undefined' || !settings['user']) && typeof settings['an'] != 'undefined' && settings['an'])
      settings['user'] = ae(settings['an']);
    if ((typeof settings['password'] == 'undefined' || !settings['password']) && typeof settings['ap'] != 'undefined' && settings['ap'])
      settings['password'] = ae(settings['ap']);
  }
  
  for (let subnet in settings['exclude']) {
    if (settings['exclude'][subnet] > exclude_maxprefix)
      exclude_maxprefix = settings['exclude'][subnet];
    if (settings['exclude'][subnet] < exclude_minprefix)
      exclude_minprefix = settings['exclude'][subnet];
  }
  
  for (let subnet in settings['notrouted']) {
    if (settings['notrouted'][subnet] > notrouted_maxprefix)
      notrouted_maxprefix = settings['notrouted'][subnet];
    if (settings['notrouted'][subnet] < notrouted_minprefix)
      notrouted_minprefix = settings['notrouted'][subnet];
  }
  
  document.getElementById("local-address").value = settings['localhost'];
  document.getElementById("router-address").value = settings['router'];
  document.getElementById("router-user").value = settings['user'];
  document.getElementById("router-password").value = settings['password'];
  document.getElementById("protocol-selection").value = settings['protocol'];
  document.getElementById("domain-dynamic").checked = settings['defaults']['dynamic'];
  document.getElementById("domain-time").value = settings['defaults']['time'];
  document.getElementById("domain-" + settings['defaults']['how']).checked = true;
  document.getElementById("domain-" + settings['defaults']['what']).checked = true;
  document.getElementById("top-domains").value = settings['defaults']['www'];
  
  let routes_options = '';
  let domains_options = '';
  
  for (let idx in settings['routes']) {
    routes_options += '<option value="' + settings['routes'][idx] + '">' + idx + '</option>';
    
    if (settings['routes'][idx])
      routes_query += '"list=' + settings['routes'][idx] + '",' + (routes_query ? '"#|",' : '');
  }
  
  for (let idx in settings['domains']) {
    domains_options += '<option value="' + settings['domains'][idx] + '">' + idx + '</option>';
    
    domains_regular += settings['domains'][idx] + ',';
    
    if (settings['domains'][idx])
      domains_query += '"list=' + settings['domains'][idx] + '",' + (domains_query ? '"#|",' : '');
  }
  
  document.getElementById("route-selection").innerHTML = routes_options;
  document.getElementById("domain-selection").innerHTML = domains_options;
  
  document.getElementById("settings-button").addEventListener("click", () => {
    HideViewBlock('settings-block');
  });
  
  await chrome.tabs.query({active: true}, (tabs) => {
    const tab = tabs[0];
    if (tab) {
      opened_tab = tab;
      const url = new URL(tab.url);
      const hostname = url.hostname;
      document.getElementById("current-page").innerHTML = hostname;
      
      if ((/^(\d{1,3}\.){3}\d{1,3}$/).test(hostname))
        document.getElementById("domain-button").disabled = IpIsExclude(hostname);
      
      if (hostname.indexOf('.') > 0) {
        fetch("http://api.syo.su/ipwhois?" + hostname, {
          method: "GET",
          headers: { "Accept": "application/json" }
        }).then((response) => { return response.json(); }).then((whois) => {
          document.getElementById("page-address").innerHTML = whois['ip'];
          document.getElementById("page-city").innerHTML = whois['ip2location']['city'] + ", " + whois['ip2location']['country'];
          document.getElementById("domain-button").disabled = IpIsExclude(whois['ip']);
          
          RefreshDomainInfo();
        }).catch(function() {
          SetExternalApiError();
        });
      } else {
        document.getElementById("domain-button").disabled = true;
      }
    } else {
      document.getElementById("domain-button").disabled = true;
    }
  });
  
  RefreshAddressInfo();
  
  let authuser = 'Basic ' + btoa(settings['user'] + ":" + settings['password']);
  
  fetch(settings['protocol'] + "://" + settings['router'] + "/rest/ip/firewall/address-list/print", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
    body: '{".query": [' + routes_query + '"address=' + settings['localhost'] + '","disabled=false"]}'
  }).then((response) => { return response.json(); }).then((data) => {
    document.getElementById("route-selection").value = (data.length ? data[0]['list'] : '');
  }).catch(function() {
    SetRouterApiError();
  });
  
  await fetch("http://api.syo.su/gethost?api.syo.su", {
    method: "GET",
    headers: { "Accept": "text/html" }
  }).then((response) => { return response.text(); }).then((ip) => {
    api_ip_address = ip;
    api_ip_query = '"dst-address=' + ip + ':443","dst-address=' + ip + ':80","#|"';
  });
  
  document.getElementById("route-button").addEventListener("click", () => {
    SetLocalhostRoute();
  });
  
  document.getElementById("domain-button").addEventListener("click", () => {
    SetDomainRoute();
  });
  
  document.getElementById("save-button").addEventListener("click", () => {
    SaveSettings();
  });
}

function HideViewBlock(block_name)
{
  let info_block = document.getElementById(block_name);
  info_block.hidden = !info_block.hidden;
}

function SetRouterApiError()
{
  document.getElementById("error-messages").innerHTML += "<b>Error connecting to router API</b><br>Check router address, username, password and protocol in settings";
  document.getElementById("route-button").disabled = true;
  document.getElementById("domain-button").disabled = true;
}

function SetExternalApiError()
{
  document.getElementById("error-messages").innerHTML += "<b>Error connecting to external API</b><br>Check availability http://api.syo.su";
  document.getElementById("domain-button").disabled = true;
}

function RefreshAddressInfo()
{
  fetch("http://api.syo.su/myip", {
    method: "GET",
    headers: { "Accept": "text/html" }
  }).then((response) => { return response.text(); }).then((data) => {
    document.getElementById("current-address").innerHTML = data;
    
    fetch("http://api.syo.su/ipwhois?" + data, {
      method: "GET",
      headers: { "Accept": "application/json" }
    }).then((response) => { return response.json(); }).then((whois) => {
      document.getElementById("address-city").innerHTML = whois['ip2location']['city'] + ", " + whois['ip2location']['country'];
      document.getElementById("address-provider").innerHTML = whois['ip2location']['provider'];
    }).catch(function() {
      SetExternalApiError();
    });
  }).catch(function() {
    SetExternalApiError();
  });
}

async function SetLocalhostRoute()
{
  let localhost = document.getElementById("local-address").value.replaceAll(' ', '');
  let addrlist = document.getElementById("route-selection").value;
  let address = document.getElementById("page-address").innerHTML;
  let domain = document.getElementById("current-page").innerHTML;
  let router = document.getElementById("router-address").value.replaceAll(' ', '');
  let username = document.getElementById("router-user").value;
  let userpass = document.getElementById("router-password").value;
  let protocol = document.getElementById("protocol-selection").value;
  let authuser = 'Basic ' + btoa(username + ":" + userpass);
  let is_exclude = (!address || address == '-' || IpIsExclude(address));
  let disable_from = 0;
  
  let current_list = await fetch(protocol + "://" + router + "/rest/ip/firewall/address-list/print", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
    body: '{".query": [' + routes_query + '"address=' + localhost + '"]}'
  }).then((response) => { return response.json(); }).then((data) => {
    return data;
  });
  
  let current_conn_query = api_ip_query;
  if (!is_exclude)
    current_conn_query += '"dst-address=' + address + ':443","dst-address=' + address + ':80","#|"' + (api_ip_query ? '"#|"' : "");
  
  let current_conn = await fetch(protocol + "://" + router + "/rest/ip/firewall/connection/print", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
    body: '{".query": [' + current_conn_query + ']}'
  }).then((response) => { return response.json(); }).then((data) => {
    return data;
  });
  
  let gateway_address = null;
  if (current_conn.length)
    gateway_address = current_conn[0]['reply-dst-address'].split(':')[0];
  
  if (addrlist) {
    disable_from = 1;
    let fetch_method = null;
    let fetch_url = null;
    let sendbody = '"address":"' + localhost + '","disabled":"false","dynamic":"false","list":"' + addrlist + '"';
    
    if (current_list.length) {
      fetch_method = "PATCH";
      fetch_url = protocol + "://" + router + "/rest/ip/firewall/address-list/" + current_list[0]['.id'];
      let comment = (typeof current_list[0]['comment'] != 'undefined' ? ',"comment":"' + current_list[0]['comment'] + '"' : "");
      sendbody = '".id":"' + current_list[0]['.id'] + '",' + sendbody + comment;
    } else {
      fetch_method = "POST";
      fetch_url = protocol + "://" + router + "/rest/ip/firewall/address-list/add";
    }
    
    await fetch(fetch_url, {
      method: fetch_method,
      headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
      body: '{' + sendbody + '}'
    }).then((response) => { return response; });
  }
  
  for (let i = disable_from; i < current_list.length; i++)
    if (current_list[i]['disabled'] == 'false')
      await fetch(protocol + "://" + router + "/rest/ip/firewall/address-list/" + current_list[i]['.id'], {
        method: "PATCH",
        headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
        body: '{".id":"' + current_list[i]['.id'] + '","address":"' + localhost + '","disabled":"true","dynamic":"' + current_list[i]['dynamic'] + '","list":"' + current_list[i]['list'] + '"}'
      }).then((response) => { return response; });
  
  for (let i = 0; i < current_conn.length; i++) {
    if (localhost == current_conn[i]['src-address'].split(':')[0]) {
      let check_address = await fetch(protocol + "://" + router + "/rest/ip/firewall/connection/" + current_conn[i]['.id'], {
        method: "DELETE",
        headers: { "Accept": "application/json", "Authorization": authuser }
      }).then((response) => { return response; });
    }
  };
  
  setTimeout(RefreshAddressInfo, 1000);
  
  if (!is_exclude)
    setTimeout(function() { chrome.tabs.reload(opened_tab.id); }, 1000);
}

function RefreshDomainInfo()
{
  let domain = document.getElementById("current-page").innerHTML;
  let address = document.getElementById("page-address").innerHTML;
  let router = document.getElementById("router-address").value.replaceAll(' ', '');
  let username = document.getElementById("router-user").value;
  let userpass = document.getElementById("router-password").value;
  let protocol = document.getElementById("protocol-selection").value;
  let authuser = 'Basic ' + btoa(username + ":" + userpass);
  
  fetch(protocol + "://" + router + "/rest/ip/firewall/address-list/print", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
    body: '{".query": [' + domains_query + '"comment=' + domain + '","address=' + address + '","#&","address=' + domain + '","#|","disabled=false","timeout","#!","dynamic=true","#&!"]}'
  }).then((response) => { return response.json(); }).then((data) => {
    if (data.length) {
      document.getElementById("domain-time").value = (data[0]['dynamic'] == 'true' ? data[0]['timeout'] : settings['defaults']['time']);
      document.getElementById("domain-dynamic").checked = (data[0]['dynamic'] == 'true');
      document.getElementById("domain-selection").value = data[0]['list'];
    } else {
      document.getElementById("domain-time").value = settings['defaults']['time'];
      document.getElementById("domain-dynamic").checked = settings['defaults']['dynamic'];
      document.getElementById("domain-selection").value = "";
    }
  });
}

async function SetDomainRoute()
{
  let domain = document.getElementById("current-page").innerHTML;
  let address = document.getElementById("page-address").innerHTML;
  let addrlist = document.getElementById("domain-selection").value;
  let dynamic = document.getElementById("domain-dynamic").checked;
  let dyntime = document.getElementById("domain-time").value.replaceAll(' ', '');
  let router = document.getElementById("router-address").value.replaceAll(' ', '');
  let username = document.getElementById("router-user").value;
  let userpass = document.getElementById("router-password").value;
  let protocol = document.getElementById("protocol-selection").value;
  let top_domains_template = ',' + document.getElementById("top-domains").value.replaceAll(' ', '').toLowerCase() + ',';
  let as_address = document.getElementById("domain-address").checked;
  let add_www = document.getElementById("domain-www").checked;
  let add_all = document.getElementById("domain-all").checked;
  let authuser = 'Basic ' + btoa(username + ":" + userpass);
  let is_exclude = (!address || address == '-' || IpIsExclude(address));
  
  if (is_exclude)
    return 0;
  
  let domains = new Array();
  let addresses = new Array();
  let error_messages = new Array();
  
  if (domain != address) {
    let domain_struct = domain.split('.');
    let steps_count = 1;
    if (add_www) {
      if (top_domains_template.indexOf(',' + domain_struct[0] + ',') >= 0)
        steps_count = 2;
    } else if (add_all) {
      steps_count = domain_struct.length - 1;
    }
    
    let domain_next = domain;
    for (let i = 0; i < steps_count; i++) {
      domains[domain_next] = new Array();
      
      if (as_address) {
        let hosts = await fetch("http://api.syo.su/gethosts?" + domain_next, {
          method: "GET",
          headers: { "Accept": "text/html" }
        }).then((response) => {
          return response.text().split('<br>');
        }).catch(function() {
          return null;
        });
        
        if (hosts !== null) {
          for (let i = 0; i < hosts.length; i++)
            if (!addresses.includes(hosts[i])) {
              addresses.push(hosts[i]);
              domains[domain_next].push(hosts[i]);
            }
        } else
          error_messages.push("Error API access");
      } else {
        domains[domain_next].push(domain_next);
      }
      
      domain_next = domain_next.substr(domain_struct[i].length + 1);
    }
  }
  
  let current_query = '';
  let current_addr_query = '';
  for (let dom in domains) {
    current_query += '"comment=' + dom + '","address=' + dom + '","#|",' + (current_query ? '"#|",' : '');
    current_addr_query += '"comment=' + dom + '",' + (current_addr_query ? '"#|",' : '');
  }
  
  let current_list = await fetch(protocol + "://" + router + "/rest/ip/firewall/address-list/print", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
    body: '{".query": [' + domains_query + current_query + '"disabled=false","timeout","#!","dynamic=true","#&!"]}'
  }).then((response) => { return response.json(); }).then((data) => {
    return data;
  });
  
  let current_addrs = new Array();
  let current_num = null;
  if (current_list.length)
    current_num = 0;
  
  if (addrlist) {
    for (let dom in domains) {
      for (let i = 0; i < domains[dom].length; i++) {
        let set_address = domains[dom][i];
        let sendbody = '"address":"' + set_address + '","list":"' + addrlist + '","disabled":"false"';
        
        if (as_address)
          sendbody += ',"comment":"' + dom + '"';
        
        if (dynamic)
          sendbody += ',"dynamic":"true","timeout":"' + dyntime + '"';
        else
          sendbody += ',"dynamic":"false"';
        
        let fetch_method = null;
        let fetch_url = null;
        if (current_num !== null && current_num < current_list.length) {
          fetch_method = "PATCH";
          fetch_url = protocol + "://" + router + "/rest/ip/firewall/address-list/" + current_list[current_num]['.id'];
          sendbody = '".id":"' + current_list[current_num]['.id'] + '",' + sendbody;
          current_num++;
        } else {
          fetch_method = "POST";
          fetch_url = protocol + "://" + router + "/rest/ip/firewall/address-list/add";
        }
        
        let check_address = await fetch(fetch_url, {
          method: fetch_method,
          headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
          body: '{' + sendbody + '}'
        }).then((response) => { return response.json(); }).then((answer) => {
          return answer;
        });
      }
    }
  } else {
    current_addrs = await fetch(protocol + "://" + router + "/rest/ip/firewall/address-list/print", {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
      body: '{".query": [' + domains_query + current_addr_query + '"disabled=false"]}'
    }).then((response) => { return response.json(); }).then((data) => {
      return data;
    });
  }
  
  if (current_num !== null)
    for (let i = current_num; i < current_list.length; i++) {
      let check_address = await fetch(protocol + "://" + router + "/rest/ip/firewall/address-list/" + current_list[i]['.id'], {
        method: "DELETE",
        headers: { "Accept": "application/json", "Authorization": authuser }
      }).then((response) => { return response; });
    }
  
  if (addrlist)
    current_addrs = await fetch(protocol + "://" + router + "/rest/ip/firewall/address-list/print", {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
      body: '{".query": [' + domains_query + current_addr_query + '"disabled=false"]}'
    }).then((response) => { return response.json(); }).then((data) => {
      return data;
    });
  
  let current_conn_query = '';
  for (let i = 0; i < current_addrs.length; i++) {
    current_conn_query += '"dst-address=' + current_addrs[i]['address'] + ':443","dst-address=' + current_addrs[i]['address'] + ':80","#|",' + (current_conn_query ? '"#|",' : '');
  }
  
  let current_conn = await fetch(protocol + "://" + router + "/rest/ip/firewall/connection/print", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": authuser },
    body: '{".query": [' + current_conn_query + ']}'
  }).then((response) => { return response.json(); }).then((data) => {
    return data;
  });
  
  for (let i = 0; i < current_conn.length; i++) {
    let check_address = await fetch(protocol + "://" + router + "/rest/ip/firewall/connection/" + current_conn[i]['.id'], {
      method: "DELETE",
      headers: { "Accept": "application/json", "Authorization": authuser }
    }).then((response) => { return response; });
  }
  
  setTimeout(function() { chrome.tabs.reload(opened_tab.id); }, 1000);
}

function SaveSettings()
{
  let localhost = document.getElementById("local-address").value;
  let dynamic = document.getElementById("domain-dynamic").checked;
  let dyntime = document.getElementById("domain-time").value;
  let router = document.getElementById("router-address").value;
  let username = document.getElementById("router-user").value;
  let userpass = document.getElementById("router-password").value;
  let protocol = document.getElementById("protocol-selection").value;
  let top_domains_template = document.getElementById("top-domains").value;
  let as_address = document.getElementById("domain-address").checked;
  let add_www = document.getElementById("domain-www").checked;
  let add_all = document.getElementById("domain-all").checked;
  
  let save_settings = {
    localhost: localhost,
    router: router,
    an: CodeString(username),
    ap: CodeString(userpass),
    protocol: protocol,
    defaults: {
      dynamic: dynamic,
      time: dyntime,
      www: top_domains_template,
      what: (add_www ? "www" : (add_all ? "all" : "top")),
      how: (as_address ? "address" : "name")
    }
  };
  
  chrome.storage.local.set({"MikroTikControlPanelSettings": save_settings }).then(() => {
    document.getElementById("settings-info").innerHTML = "Settings saved";
  });
}

const delay = (delayInms) => {
  return new Promise(resolve => setTimeout(resolve, delayInms));
};

function IpIsExclude(ip_str)
{
  let ip = ParseIp(ip_str);
  
  if (ip === null)
    return false;
  
  return IpInArray(ip, settings['exclude'], exclude_minprefix, exclude_maxprefix)
    || IpInArray(ip, settings['notrouted'], notrouted_minprefix, notrouted_maxprefix);
}

function IpInArray(ip, arr, prefmin, prefmax)
{
  let submask = new Uint32Array([0xffffffff << (32 - prefmax)])[0];
  let network = ip & submask;
  
  for (let prefix = prefmax; prefix >= prefmin; prefix--) {
    let subnet = ((network >>> 24) & 0xff).toString() + '.' + ((network >>> 16) & 0xff).toString() + '.' + ((network >>> 8) & 0xff).toString() + '.' + (network & 0xff).toString();
    
    if (typeof arr[subnet] != 'undefined' && arr[subnet] <= prefix)
      return true;
    
    submask = submask << 1;
    network = network & submask;
  }
  
  return false;
}

function ParseIp(ip_str, ip_format = 10)
{
  let ip_octets = ip_str.split('.');
  
  ip_octets[0] = parseInt(ip_octets[0], ip_format);
  ip_octets[1] = parseInt(ip_octets[1], ip_format);
  ip_octets[2] = parseInt(ip_octets[2], ip_format);
  ip_octets[3] = parseInt(ip_octets[3], ip_format);
  
  if (isNaN(ip_octets[0]) || isNaN(ip_octets[1]) || isNaN(ip_octets[2]) || isNaN(ip_octets[3])
    || ip_octets[0] > 255 || ip_octets[1] > 255 || ip_octets[2] > 255 || ip_octets[3] > 255
    || ip_octets[0] < 0 || ip_octets[1] < 0 || ip_octets[2] < 0 || ip_octets[3] < 0)
    return null;
  
  return new Uint32Array([((ip_octets[0] << 24) + (ip_octets[1] << 16) + (ip_octets[2] << 8) + ip_octets[3])])[0];
}

function ae(ai)
{
  let f = "";
  for (let c = 0; c < ai.length; c++)
    f += String.fromCharCode(ai.charCodeAt(c) ^ (1 + (ai.length - c) % 32));
  
  return f;
}

function StringToHex(str)
{
  let hex = '';
  for (let i = 0; i < str.length; i++)
    hex += str.charCodeAt(i).toString(16).padStart(2, '0');
  
  return hex;
}

function HexToString(hex)
{
  let str = '';
  for (let i = 0; i < hex.length; i += 2)
    str += String.fromCharCode(parseInt(hex.substring(i, i + 2), 16));
  
  return str;
}

function CodeString(str)
{
  return StringToHex(ae(btoa(str)));
}

function DecodeString(str)
{
  return atob(ae(HexToString(str)));
}

InitPage();

Обновление 19.05.2024 - новая версия на GitHub

Определение адресов открытой страницы теперь производится маршрутизатором. Для определения своего текущего внешнего адреса и сервиса Whois можно использовать любой API и строить ответ на своё усмотрение создавая шаблоны в файле settings.js - примеры в разделах api.myip и api.whois. В расширениях запрещены JS выражения, поэтому шаблоны работают через замену ключевых слов. Если ответ на запрос текстовый, то используем для замены на него в шаблоне обозначение [%answer%]. Если ответ JSON, то после слова answer через точку пишем имена объектов и индексы массивов [%answer.fields.1.field%]. API можно отключить, на работоспособность это не повлияет. Если API предусматривает отправку авторизации в заголовках запросов, добавьте поле authorization в описание запроса, пример есть в файле settings.js.

Улучшена стабильность работы, действующие текущие маршруты теперь определяются согласно порядка правил mangle. В поле Host при изменении адреса, если нажать ввод, будет отображён действующий маршрут для введённого адреса. Определение локального адреса своего хоста можно сделать средствами роутера по имени пользователя, но работает это только при использовании одного имени на одном устройстве - пока черновой вариант. Раз в сутки опрашивается доступность новой версии на GitHub, отключается.

Название приложения вверху окна теперь раскрывает панель с отображением загрузки процессора и списков действующих маршрутов для локальной сети и доменных имён. В файле settings.js есть тонкие настройки опросов роутера в разделе tweaks.

В Mozilla пока не получилось побороть ошибку запросов по HTTP к API роутера, теоретически, по HTTPS будет работать.

Теги:
Хабы:
Всего голосов 11: ↑10 и ↓1+10
Комментарии25

Публикации