Сегодня в программе: Куда еще можно применить Google Apps Script, если закончились нормальные идеи. Автоматизация работы с VPNBook через цепочку скриптов на разных языках, которые я не знаю. Недо-cURL от Mikrotik. Telegram через одно место, чтобы не оказаться в другом, самкомнадзор разрешает.
Часть 1. Без названия
Год назад я написал заметку "Почти OCR для получения пароля VPNBook. PHP + Mikrotik" про то, как настроить автоматическое получение пароля в маршрутизаторе Mikrotik для бесплатного VPN доступа через VPNBook. Начало истории там.
С тех пор утекло много воды, в России заблокировали сайт VPNBook, но не сами публичные VPN сервера, которые на нем опубликованы. Тот PHP скрипт для декодирования PNG изображения пароля в текстовую строку сейчас тоже должен работать при запуске на сервере, трафик которого не пропускается через систему блокировки. Но некоторое время назад, экспериментируя с сервисом Google Apps Script (GAS) script.google.com, я решил отказаться от PHP скрипта на внешнем web сервере, заменив его частично или полностью скриптом GAS, запущенным как Web App (web приложение). Я не разбирался с политикой выполнения и ограничениями GAS, но все что я сделал, работает в бесплатном аккаунте Google и пока не просит денег. У меня нет цели подробно описывать Google Apps Script. GAS базируется на языке JavaScript, можно использовать сторонние библиотеки JS, можно опубликовать скрипт как web приложение, которое можно сделать доступным всем без авторизации. Возможностей текущей реализации GAS мне не хватило, поэтому пришлось выкручиваться и искать обходные пути.
Сначала я решил написать proxy для PNG изображений. Web скрипт должен был запрашивать изображение пароля с сайта VPNBook (напомню, что пароль там опубликован в PNG) и отдавать его клиенту, вызвавшему этот скрипт для декодирования. Такой вот способ обойти блокировку. Тут встретилось первое ограничение GAS. Оказывается, скрипт не может отдавать MIME image/png, а только текстовые форматы, JSON, TEXT, XML и т.д. Но нашелся способ, как это обойти. Можно закодировать PNG в Base64 и вернуть клиенту текстовую строку. Подобные скрипты есть в Internet, например techslides.com/image-proxy-with-google-app-scripts. Я лишь упростил один из них. Мне нужно было только одно изображение и выдача только Base64 строки. Получился скрипт, состоящий только из одной функции doGet — обработчика GET запросов, которая возвращает в ответ строку.
function doGet() {
var response = UrlFetchApp.fetch('https://www.vpnbook.com/password.php');
var b64 = Utilities.base64Encode(response.getContent());
//var data = 'data:'+type+';base64,'+b64;
return ContentService.createTextOutput(b64);
}
Пример вывода в браузер:
iVBORw0KGgoAAAANSUhEUgAAAGQAAAANAQMAAABl11mFAAAABlBMVEX29vZMTExY89ZbAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAVUlEQVQImWNgIBrwSzCw/2ZgOADhSc5gYJCG8wxQedLdCcYFNXcgPHOZsxuSZxx7BuFZzsjdcJi34TBU5Y3cjc3IvM3McJ7kjNxtzDwwffwSIB7UTACt/h52C5DFqQAAAABJRU5ErkJggg==
Далее вступает в работу PHP скрипт, который может быть размещен на сервере внутри зоны с блокировкой ресурсов. Он очень похож на скрипт из предыдущей статьи, за исключением небольшого изменения в параметрах вызова cURL. Нужно разрешить cURL переходить по HTTP/1.1 302 Moved Temporarily, т.к. GAS при вызове делает переадресацию с адреса web скрипта на динамический временный адрес:
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
И декодирование Base64:
$imgOCR = imagecreatefromstring(base64_decode($output));
<?php
// размер символа
$wchar = 9;
$hchar = 13;
$strDict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 ';
$imgDict = imagecreatetruecolor(2 + strlen($strDict)* $wchar, $hchar);
$bg = imagecolorallocate($imgDict, 0xF6, 0xF6, 0xF6);
$textcolor = imagecolorallocate($imgDict, 0x4C, 0x4C, 0x4C);
imagefill($imgDict, 0, 0, $bg);
imagestring($imgDict, 5, 2, 0, $strDict, $textcolor);
// инициализируем cURL
$ch = curl_init();
// устанавливаем url, с которого будем получать данные
//curl_setopt($ch, CURLOPT_URL, 'https://www.vpnbook.com/password.php');
curl_setopt($ch, CURLOPT_URL, 'https://script.google.com/macros/s/AKfycbwYPfaZobtjbFv0mSYI8U4NIXPh1Sft_DkGH8QKgg/exec');
// устанавливаем опцию, чтобы содержимое вернулось нам в string
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_BINARYTRANSFER, 1); // also, this seems wise considering output is image.
// выполняем запрос
$output = curl_exec($ch);
// закрываем cURL
curl_close($ch);
// echo $output;
$imgOCR = imagecreatefromstring(base64_decode($output));
//$imgOCR = imageCreateFromPng('password.png');
// в текущее изображение может поместиться 10 полных символов. 2 + 10*9 = 92 < 100
$maxchar = floor((imagesx($imgOCR) - 2) / 9);
$imgBox = imagecreatetruecolor($wchar, $hchar);
$hashDict = Array();
// генерируем словарь
for ($k = 0; $k < strlen($strDict) ; $k++) {
imagecopy($imgBox, $imgDict, 0, 0, 2 + $k * $wchar, 0, $wchar, $hchar);
$hashStr = "";
for($y = 0; $y < $hchar ; $y++)
for($x = 0; $x < $wchar; $x++) $hashStr .= (imagecolorat($imgBox, $x, $y) != 0xF6F6F6)? '1': '0';
$hashDict[$hashStr] = $strDict[$k];
}
// ищем символы по словарю
for ($k = 0; $k < $maxchar ; $k++) {
imagecopy($imgBox, $imgOCR, 0, 0, 2 + $k * $wchar, 0, $wchar, $hchar);
$hashStr = "";
for($y = 0; $y < $hchar ; $y++)
for($x = 0; $x < $wchar; $x++) $hashStr .= (imagecolorat($imgBox, $x, $y) != 0xF6F6F6)? '1': '0';
$tempchar = $hashDict[$hashStr];
if ($tempchar=='u' || $tempchar=='y') // проблема совпадения символов
$tempchar = (mt_rand(0, 1))? 'u': 'y';
//$tempchar = (time() / 60 % 60 % 2)? 'u': 'y';
elseif ($tempchar==' ') break;
print($tempchar);
}
/*
header('Content-type: image/png');
imagepng($imgDict);
*/
//var_dump($hashDict);
imagedestroy($imgDict);
imagedestroy($imgOCR);
imagedestroy($imgBox);
?>
Этот PHP скрипт декодирует PNG пароль и возвращает его в виде текстовой строки. Далее как в первой статье в части про Mikrotik. Маршрутизатор забирает пароль с помощью fetch.
Получилась вот такая работающая схема из 2х промежуточных сервисов перед Mikrotik.
Часть 2. Дави на GAS. Избавляемся от PHP скрипта декодера
Во время экспериментов с GAS возникла идея вообще отказаться от декодера пароля в PHP, переписав его в GAS. И тут обнаружилась большая проблема: Google script не имеет функций обработки PNG, единственное что можно сделать — это преобразовать PNG в байтовый массив. Ни о каких манипуляциях с частями изображения и пикселями не было и речи. Я полез на Github в поисках библиотеки JS для работы с PNG, нашел таких немало: PNG.js, UPNG.js, pngjs. Некоторые не поддерживают 1-битную глубину цвета пикселя PNG (изображение с паролем). Они потянули за собой различные библиотеки сжатия zlib. В общем, мне показалось все это несколько громоздким, и я решил написать самостоятельно примитивный конвертер только для моего PNG изображения в битовый массив с функцией доступа к пикселям по координатам XY. Тут наступило полное погружение в формат PNG: hex-редактор, чтение стандартов, кучи описаний в сети. И наконец я уперся в секцию PNG файла IDAT, запакованную zlib, которая содержала массив пикселей.
Требовалась функция для распаковки zlib, которой конечно же в GAS не оказалось. Удивительно, у них есть gzip/ungzip и zip/unzip, но нет zlib. Почитав про gzip (второй уровень погружения после формата PNG), я пришел к выводу, что не получится собрать «велосипед» в виде квазиархива gzip из секции IDAT, хотя и там и там используется zlib сжатие. Т.к. для сборки валидного архива gzip нужно знать длину распакованных данных, которую я не мог получить не распаковав их :) А с неправильной длинной GAS считал архив поврежденным. В итоге я обратился к Github и нашел отличное решение: библиотеку zlib.js for Google Apps Script (https://github.com/hinimub/zlib.js/blob/develop/README.en.md). Которая была специально подготовлена для интеграции в GAS проекты через project key библиотеки. Дальше пазл начал сходиться. После написания распаковки массива пикселей и функции для доступа по координатам XY пикселя можно было переносить скрипт декодера из PHP в GAS.
Отдельно рассчитал хэш-таблицу словаря возможных символов пароля. Это разовое действие, которое я проделал в сторонней программе (в LabVIEW, привет, коллеги). Каждый символ в изображении можно выделить как 8 бит (без учета отступов) x 10 строк. Достаточно 1 байта для кодирования 8 пикселей строки одного символа. Можно хранить строку пикселей в целом числе (byte), а весь символ как последовательность 10 байт. Получается 10 hex чисел на символ. Далее GAS декодер повторяет своего PHP-ого прародителя.
function doGet() {
//var file = DriveApp.getFilesByName("password2.png").next();
//var image = file.getBlob();
var image = UrlFetchApp.fetch('https://www.vpnbook.com/password.php').getBlob();
var imageString = image.getDataAsString();
var imageArray = image.getBytes().map(function(e) { return e & 0xff; });
// imageArray = blobToUint8(imageArray);
var chunkIDATStart = imageString.indexOf("IDAT") + 4; // начало секции IDAT
var chunkIDATLen = bytesToUint32(imageArray, imageString.indexOf("IDAT") - 4); // размер секции IDAT
var IDATArray = imageArray.slice(chunkIDATStart, chunkIDATStart + chunkIDATLen)
var inflate = new zlibjs.Inflate(IDATArray); // распаковка IDAT с помощью zlib
var plain = inflate.decompress();
const Width = 100; // ширина изображения
const Height = 13; // высота изображения
const wchar = 9; // ширина символа
const hchar = 13; // высота символа
//Logger.log(typeof(plain));
//Logger.log(plain);
rowlen = (Width / 8) >> 0; // целое число байт, длина строки пикселей
if ((Width - rowlen * 8) > 0) { // остаток от деления
rowlen+=2; // +1 filter byte at the beginning of each row. Из описания формата PNG
} else {
rowlen++;
}
function getXY(x, y) { // значение пикселя: 0/1
var xbyte = (x / 8 >> 0); // номер байта, целое деления
//Logger.log("xbyte: " + xbyte);
var xbit = x - xbyte * 8; // номер бита, остаток от деления
//Logger.log("xbit: " + xbit);
return (plain[xbyte + 1 + y * rowlen] << xbit & 0x80) >> 7; // +1 filter byte at the beginning of each row
}
// Logger.log("getXY: " + getXY(4, 3));
// Хэш-таблица символов. Каждый символ в изображении можно выделить как 8 бит (без учета отступов) x 10 строк, поэтому можно хранить строку в целом числе (byte), а весь символ как последовательность 10 целых чисел.
// Получается 10 hex чисел на символ.
var hashDict = {'183C66C3C3C3FFC3C3C3':'A','FCC6C3C6FCC6C3C3C6FC':'B','3E63C1C0C0C0C0C1633E':'C','FCC6C3C3C3C3C3C3C6FC':'D', 'FEC0C0C0FCC0C0C0C0FE':'E','FFC0C0C0FCC0C0C0C0C0':'F','3E63C0C0C0C7C3C3633E':'G','C3C3C3C3FFC3C3C3C3C3':'H', '7E18181818181818187E':'I','1E666666466C38':'J','C3C6CCD8F0F0D8CCC6C3':'K','C0C0C0C0C0C0C0C0C0FE':'L', 'C3E7FFDBDBDBC3C3C3C3':'M','C3E3F3F3DBDBCFC7C7C3':'N','3C66C3C3C3C3C3C3663C':'O','FEC3C3C3FEC0C0C0C0C0':'P', '3C66C3C3C3C3DBCF663D':'Q','FEC3C3C3FEF8CCC6C3C3':'R','7EC3C0C07E333C37E':'S','FF181818181818181818':'T', 'C3C3C3C3C3C3C3C3663C':'U','C3C3C36666663C3C1818':'V','C3C3C3C3DBDBDBFFE7C3':'W','C3C3663C18183C66C3C3':'X', 'C3C3663C181818181818':'Y','FE66C183060C0C0FE':'Z','0003E6337FC3C77B':'a','C0C0C0DCE6C3C3C3E6DC':'b', '0003E63C0C0C0633E':'c','3333B67C3C3C3673B':'d','0003C66C3FFC0633E':'e','1E33333030FC30303030':'f', '0007DC7C6C67CC07E':'g','C0C0C0DCE6C3C3C3C3C3':'h','181803818181818187E':'i','660E66666C6':'j', '606060666C78786C6663':'k','3818181818181818183C':'l','000B6DBDBDBDBDBDB':'m','000DCE6C3C3C3C3C3':'n', '0003C66C3C3C3663C':'o','000DCE6C3C3C3E6DC':'p','0003B67C3C3C3673B':'q','000DE736060606060':'r', '0007EC3C07E3C37E':'s','03030FC30303030331E':'t','000C3C3C3C3C3673B':'u','000C3C366663C3C18':'v', '000C3C3DBDBDBFF66':'w','000C3663C183C66C3':'x','000C3C3C3C3C3673B':'y','0007E6C1830607E':'z', '183C66C3C3C3C3663C18':'0','1838781818181818187E':'1','3C66C336C183060FF':'2','7CC6361C633C67C':'3', '6E1E3666C6FF666':'4','FEC0C0DCE633C3663C':'5','3C66C2C0DCE6C3C3663C':'6','FF336C183060C0C0':'7', '3C66C3663C66C3C3663C':'8','3C66C3C3673B343663C':'9','0000000000':' '};
// в текущее изображение может поместиться 10 полных символов. 2 + 10*9 = 92 < 100
const maxchar = (Width - 2) / wchar >> 0;
var password = '';
for (var charX = 2; charX < maxchar * wchar + 2; charX+=wchar) { // проход по символам
var hash = ''; // хэш символа
for (var charY = 3; charY < hchar; charY++) { // проход по Y-строкам
var charrow = 0; // целое число - значение 8бит строки пикселей
for (var charXbit = 0; charXbit < 8; charXbit++) { // проход по X-стобцам
charrow <<= 1;
charrow |= getXY(charX + charXbit, charY);
}
hash += charrow.toString(16).toUpperCase();
//Logger.log("charrow: " + charrow.toString(2));
//Logger.log("charrow: " + charrow.toString(16).toUpperCase());
}
var tempChar = hashDict[hash];
if (tempChar === 'u' || tempChar === 'y') { // решение проблемы совпадения символов
tempChar = (Date.now() % 2) ? 'u': 'y';
}
if (tempChar !== ' ') {
password += tempChar;
// Logger.log("hash: " + hash);
// Logger.log("Char: " + tempChar);
}
}
Logger.log("password: " + password);
return ContentService.createTextOutput(password);
}
function blobToUint8(blob) {
return blob.map(function(e){
return e & 0xff;
});
}
function bytesToUint32(byteArray, start) {
var value = 0;
for (var i = start; i < start + 4; i++) {
value = (value * 256) + (byteArray[i] & 0xff);
}
return value;
}
function my2() {
var file = DriveApp.getFilesByName("password2.png").next();
// var file = DriveApp.getFilesByName("test.bin").next();
var image = file.getBlob();
//var imageArray = image.getBytes();
//var img = UrlFetchApp.fetch('http://example.com/image.png');
var reader = new pngjs.PNGReader(image.getBytes());
var png = reader.parse(function(err, png){
if (err) throw err;
return png;
});
Logger.log(png);
}
В скрипте реализован только метод GET. При выполнении запроса GET к этому скрипту, опубликованному как Web App, в ответе будет содержаться сразу декодированный пароль в виде строки.
Часть 3. Mikrotik и Moved Temporarily 302
Итак, у нас есть выполняемый на внешних серверах Web App скрипт, который не зависит от блокировок и возвращает plain text пароль. И кажется, нет ничего проще, чем запросить его с помощью команды fetch в RouterOS Mikrotik. Но тут меня ожидал очередной сюрприз. В ответ на запрос (реальные адреса изменены) fetch выдает «302 Moved Temporarily».
[admin@MikroTik] /environment> :put ([/tool fetch url="https://script.google.com/macros/s/A.....A/exec" http-method=get output=user as-value]->"data")
failure: closing connection: <302 Moved Temporarily "https://script.googleusercontent.com/macros/echo?user_content_key=....."> 173.194.222.138:443 (4)
[admin@MikroTik] /environment>
В начале статьи я об этом уже писал. При обращение к постоянному известному URL Web App скрипта Google делает переадресацию на временный URL, который в свою очередь и возвращает ответ на запрос. Но в отличие от PHP-ого cURL, feth RouterOS не умеет переходить по переадресации, вместо этого возвращает failure. Но на forum.mikrotik.com не сразу, но нашлось обходное решение. Можно перенаправить стандартный вывод fetch из консоли в файл, если вызвать асинхронное выполнение в отдельной задаче, обернув командой :execute. Затем можно извлечь URL переадресации и повторно выполнить fetch уже с новым адресом. Что и сделано ниже.
# Первый запрос. Moved Temporarily 302. Вывод fetch в gasfetchout.txt
:local jobid [:execute script={/tool fetch url="https://script.google.com/macros/s/A.....A/exec" output=user as-value} file=gasfetchout.txt]
# Ожидание конца работы задачи, проверка раз в секунду
:while ([:len [/system script job find .id=$jobid ]] > 0) do={ delay 1s }
# Парсинг gasfetchout.txt, извлечение URL переадресации
:local fetchOut [/file get gasfetchout.txt contents]
:local startURL [:find $fetchOut "http" -1]
:local endURL [:find $fetchOut ""> " startURL]
:local moveURL [:pick $fetchOut $startURL $endURL]
:global VPNBookPass2 ([/tool fetch url=$moveURL output=user as-value]->"data")
# VPNBookScript v4
:local VPNBookpIfName "pptp-out1"
:local VPNBookServerAddresses {"PL226.vpnbook.com";"de4.vpnbook.com";"us1.vpnbook.com";"us2.vpnbook.com";"fr1.vpnbook.com ";"fr8.vpnbook.com ";"ca222.vpnbook.com ";"ca198.vpnbook.com"}
:local VPNBookErr false
:global VPNBookPass
:global VPNBookRun
:global VPNBookServerIndex
:if ([:typeof $VPNBookServerIndex] != "num") do={:set VPNBookServerIndex 0}
:if ([/interface pptp-client get $VPNBookpIfName running]) do={
:set VPNBookRun true
} else {
:if (!$VPNBookRun) do={
:set VPNBookServerIndex ($VPNBookServerIndex + 1)
:if ($VPNBookServerIndex>=[:len $VPNBookServerAddresses]) do={:set VPNBookServerIndex 0}
} else {
:set VPNBookRun false
}
:if (![/interface pptp-client get $VPNBookpIfName disabled]) do={/interface pptp-client set $VPNBookpIfName disabled=yes}
# :do {:set VPNBookPass ([/tool fetch url="http://serv/vpnbookpass_googlescript.php" output=user as-value]->"data")} on-error={:set VPNBookErr true}
:do {
# First request with Moved Temporarily. Fetch out to gasfetchout.txt
:local jobid [:execute script={/tool fetch url="https://script.google.com/macros/s/A.....g/exec" output=user as-value} file=gasfetchout.txt]
# Wait end job
:while ([:len [/system script job find .id=$jobid ]] > 0) do={ delay 1s }
# parse new URL for second fetch
:local fetchOut [/file get gasfetchout.txt contents]
:local startURL [:find $fetchOut "http" -1]
:local endURL [:find $fetchOut ""> " startURL]
:local moveURL [:pick $fetchOut $startURL $endURL]
:set VPNBookPass ([/tool fetch url=$moveURL output=user as-value]->"data")
} on-error={:set VPNBookErr true}
:if (!$VPNBookErr) do={
:if ([/interface pptp-client get $VPNBookpIfName password] != $VPNBookPass) do={/interface pptp-client set $VPNBookpIfName password=$VPNBookPass}
:if ([/interface pptp-client get $VPNBookpIfName connect-to] != $VPNBookServerAddresses->$VPNBookServerIndex) do={/interface pptp-client set $VPNBookpIfName connect-to=($VPNBookServerAddresses->$VPNBookServerIndex)}
:log info "VPNBook: Attempt to connect to: $($VPNBookServerAddresses->$VPNBookServerIndex). Password: $VPNBookPass"
/interface pptp-client set $VPNBookpIfName disabled=no
}
}
Часть 4. Telegram GAS proxy
Эту часть я решил посвятить очередной итерации интеграции сервиса Telegram в Mikrotik. Использование тут GAS имело бы чисто академический интерес, если бы не реальность блокировки сервиса Telegram, в том числе api.telegram.org, через который боты работают с сервисом. Задумка повторяет идею в начале статьи про проксирование запроса PNG изображений.
В данном случае пишется GAS Web App для проксирования запросов от Mikrtotik к api.telegram.org. За основу я взял готовый скрипт от manzoorwanijk, WPTelegram Google Script gist.github.com/manzoorwanijk/ee9ed032caedf2bb0c83dea73bc9a28e. Этот скрипт может проксировать множество методов Telegram API (но не все). В args можно передать JSON объект, содержащий параметры запроса, например {"chat_id":"123","text":"HelloWorld"}
. Но для моей задачи отправки текстовых сообщений из RouterOS Mikrtotik реализация показалась переусложненной, и я ее упростил. В конечном итоге можно вообще написать несколько скриптов Web App для проксирования различных методов Telegram API. Вот моя реализация для метода sendMessage. Ее можно еще упростить, встроив название вызываемого метода sendMessage, и даже bot_token и chat_id в тело функции requestHandler.
function doGet(e) {
if(typeof e !== 'undefined'){
return ContentService.createTextOutput(requestHandler(e));
}
}
function doPost(e) {
if(typeof e !== 'undefined'){
return ContentService.createTextOutput(requestHandler(e));
}
}
function requestHandler(e){
if (typeof e.parameter.bot_token === 'undefined'){
return 'Error! Bot token not provided';
} else if (typeof e.parameter.method === 'undefined') {
return 'Error! Method name not provided';
} else if (typeof e.parameter.chat_id === 'undefined') {
return 'Error! Chat id not provide';
} else if (typeof e.parameter.text === 'undefined') {
return 'Error! Text not provide';
}
/* if(typeof e.parameter.args !== 'undefined'){
var args = e.parameter.args;
data.payload = JSON.parse(args);
} */
if (e.parameter.method === 'sendMessage') {
var data = {
"method": "post",
"muteHttpExceptions": true,
payload : 'chat_id=' + e.parameter.chat_id + '&text=' + e.parameter.text
}
return UrlFetchApp.fetch('https://api.telegram.org/bot' + e.parameter.bot_token + '/' + e.parameter.method, data).getContentText();
}
}
После публикаци скрипта в Web App можно выполнить в браузере GET запрос для проверки:
https://script.google.com/macros/s/A.....A/exec?bot_token=3.....3&method=sendMessage&chat_id=2.....3&text=testtext123
Либо в RouterOS POST запрос:
:do {
/tool fetch url=("https://script.google.com/macros/s/A.....A/exec") keep-result=no http-method=post http-data=("bot_token=3.....3&method=sendMessage&chat_id=2.....3&text=testtext123")
} on-error={
}
Запрос обернут в do-on-error, потому что, как было показано выше, первый вызов fetch вернет исключение «Moved Temporarily 302» и скрипт без обработчика on-error остановится в этом месте. Достаточно одного вызова fetch без переадресации, чтобы сообщение было отправлено, поэтому второй вызов fetch не обязателен, если вам не нужен JSON объект, возвращаемый API Telegram.
Часть 5. Заключительная
Я привел свои реальные приложения на стыке Google Apps Script с другими сервисами. Можно много чего еще придумать. Например, написать Telegram bot в GAS, который будет отвечать паролем VPNBook с кэшированием запросов для уменьшения нагрузки на VPNBook (Cache Service), и все это будет в одном скрипте GAS. Можно написать на GAS систему логирования или бекапа конфигураций для Mikrtotik, которая будет размещаться в файлах Google Docs и Google Sheets и многое другое.
Автор: Chupakabra303