javascript

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

  • четверг, 8 февраля 2024 г. в 00:00:17
https://habr.com/ru/articles/791932/

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

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

Приложение совсем не энтерпрайз, скорее, админский скрипт с нормальным интерфейсом. Публикую сейчас, пока данная версия универсальна для любого Микротика и может кому-то оказаться полезной, дальше могу уйти в написание собственного 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="" onkeyup="" 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="" onkeyup="" 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="" onkeyup="" 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="" onkeyup="" 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="" onkeyup="" 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="" onkeyup="" 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;
  width: 200px;
}
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();

Полная версия в архиве: https://syo.su/download/MikroTikControlPanel.zip

Можно переписать плагин для работы с API других систем, будет время, сделаю версию для OPNsense и OpenWRT. Много чего удобного можно сделать в плагине с роутером – логи, мессенджер, самая разная автоматизация.