Автоматизация загрузки логов из Kibana в Redmine

    Типичный юзкейс для Kibana — смотрим логи, видим ошибки, создаем тикеты по ним. Логов у нас довольно много, места для их хранения мало. Поэтому просто вставить ссылку на документ из Elasticsearch/Kibana недостаточно, особенно для низкоприоритетных задач: пока доберемся до нее, индекс с логом может быть уже удален. Соответственно, приходится документ сохранять в файл и прикреплять к тикету.

    Если один раз это делать, то это еще куда ни шло, но создавать уже десять тикетов подряд будет тупо лень, поэтому я решил это «быстренько» (ха-ха) автоматизировать.


    Под катом: статья для пятницы, экспериментальная фича javascript, пара грязных хаков, небольшая регулярка с галочками, reverse proxy, проигрыш безопасности удобству, костыли и очевидная картинка из xkcd.

    Предупреждаю: я далеко не специалист в web-технологиях, и поэтому специалистам, скорее всего, покажутся мои проблемы очень очевидными, а решения — тупыми. Но это не продакшн-решение, а просто мелкий скрипт «для своих». Все происходит в доверенной локальной сети и поэтому скрипт имеет много проблем с безопасностью.

    Варианты решения


    Сходу можно придумать достаточно много решений проблемы. Во-первых, можно пихать сразу все логи в RM (внезапно, для этого даже есть плагин logstash), предварительно их фильтруя/агрегируя — знай себе меняй описание да исполнителя. Это, конечно, прикольно, но надо будет долго отлаживать/настраивать и появится много новой рутинной работы — давать описания/удалять лишнее.

    Второй вариант — намастрячить какой-нибудь скрипт, который получает ссылки на логи, скачивает их, спрашивает дополнительные параметры у пользователя и через API Redmine создает новый тикет. Но к этому надо будет нормальный интерфейс пилить, да и дублировать часть функций RM…

    Можно извратиться и сделать кликер или с помощью selenium подготовить тикет, чтобы потом в привычном интерфейсе дозаполнить что надо, но нельзя будет трогать мышку… Да и редактирование может вдруг понадобиться.

    Плагин для браузера? Окститесь, его еще регистрировать и поддерживать, да еще хотя бы под два браузера делать.

    Плагин Redmine? Не, это ж API надо будет изучать, да и лезть в кишки RM… Простого дополнительного поля недостаточно будет.

    В итоге приходим к букмарклету (выполнению javascript из закладок) и/или пользовательскому скрипту (greasemonkey/tampermonkey и т.п.) — javascript‘ом вроде можно и интерфейс нарисовать, и логи скачать через ajax-запрос, да и вообще почти все что угодно со страницей сделать.

    Загрузка файлов


    Пока самая неясная часть — это загрузка файлов. Все остальное вроде можно легко сделать… За загрузку файлов на странице создания тикета RM отвечает обычный <input type="file">, при изменении которого вызывается функция addInputFiles(this).

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



    Сделано это ради того, чтобы нельзя было отправить на сервер /etc/passwd, /etc/shadow/ или фото вашего кота с рабочего стола. В принципе, разумно, но надо это как-то обойти. Впрочем, если нельзя, но очень хочется, то можно заиспользовать такой грязный хак, который основан экспериментальной фиче — Clipboard API.

    function createFileList(files){
        const dt = new ClipboardEvent("").clipboardData || new DataTransfer();
        for (let file of files) {
            dt.items.add(file);
        }
        return dt.files;
    }

    Т.е. тут имитируется добавление файлов из буфера обмена, которые мы потом получаем списком. Сам по себе «файл» из текста создается элементарно:

    function createFile(text, fileName){
        let blob = new Blob([text], {type: 'text/plain'});
        let file = new File([blob], fileName);
        return file;
    }

    Пользовательский интерфейс


    Тут все просто, как топор: делаем в нужном месте надпись, поле ввода и кнопку загрузки. Поскольку делается «для своих» с форматом ввода (и его валидацией) особо не стал заморачиваться — пусть будет текстовое поле, одна строка — один лог (ссылка и имя создаваемого файла через пробел).

    Для букмарклета еще пригодилось предварительное удаление себя по id.

    Элементарные вещи
    function removeSelf(){
        let old = document.getElementById(ui_id);
        if (old != null) old.remove();
    }
    
    function createUi(){
        removeSelf();
    
        let ui = document.createElement('p');
        ui.id = ui_id;
    
        let label = document.createElement('label');
        label.innerHTML = "Logs data:";
        ui.appendChild(label);
    
        let textarea = document.createElement('textarea');
        textarea.id = data_id;
        textarea.cols = 60;
        textarea.rows = 10;
        textarea.name = "issue[logs_data]";
        ui.appendChild(textarea);
    
        let button = document.createElement('button');
        button.type = "button";
        button.onclick = addLogsData;
        button.innerHTML = "Add logs data";
        ui.appendChild(button);
    
        let attributesBlock = document.querySelector("#attributes");
        attributesBlock.parentNode.insertBefore(ui, attributesBlock);
    }


    Основная работа


    Здесь тоже все просто: разбиваем текст из поля ввода на пары «ссылка»-«имя файла», скачиваем все из эластика, потому что Kibana так просто данные не отдаст, заливаем на RM, изменяем описание тикета и все. Благо к RM уже подключен jquery и ajax-запросы легко создаются.

    Скучный код, регулярку искать здесь
    function addLogsData(){
        let text = document.getElementById(data_id).value;
        let lines = text.split('\n');
        let urlsAndNames = lines
            .filter(x => x.length > 2)
            .map(line => line.split(/\s+/, 2));
        downloadUrlsToFiles(urlsAndNames);
    }
    
    const kibana_pattern = /http:\/\/([^:]*):\d+\/app\/kibana#\/doc\/[^\/]*\/([^\/]*)\/([^\/]*)\/?\?id=(.*?)(&.*)?$/;
    const es_pattern = 'http://$1:9200/$2/$3/$4';
    
    function downloadUrlsToFiles(urlsAndNames){
        let requests = urlsAndNames.map((splitted) => {
            let url = splitted[0].replace(kibana_pattern, es_pattern);
            return $.ajax({
                url: url,
                dataType: 'json'
            });
        });
        $.when(...requests).done(function(...responses){
            let files = responses.map((responseRaw, index) => {
                let response = responseRaw[0];
                checkError(response);
                let fileName = urlsAndNames[index][1];
                return createFile(JSON.stringify(response._source), fileName + '.json');
            });
            uploadFiles(files, urlsAndNames);
        }).fail((error) => {
            let errorString = JSON.stringify(error);
            alert(errorString);
            throw errorString;
        });
    }
    
    function uploadFiles(files, urlsAndNames){
        pseudoUpload(files);
    
        changeDescription(urlsAndNames);
        removeSelf();
    }


    Отлично, все готово! Делаем тестовый запуск и…



    Безопасность


    Для тех кто не в курсе, запрашивать http-данные, находясь на https ресурсе, — очень плохо, потому что вам могут подпихнуть левые данные через MITM атаку. Более того, какой-нибудь Firefox даже если вам и разрешит это сделать, просить у него разрешение надо будет каждый раз — и белого списка никогда не будет. Это все правильно и хорошо с точки зрения пользователя, но для скрипта на коленке это только палки в колеса.

    Что ж, покупать X-Pack для Elasticsearch ради вшивого скрипта не хочется, поэтому придется сделать прокси https -> http. Он же reverse proxy. Вариантов тут достаточно много, от монструозного squid до питонячего скрипта. Самым подходящим мне показался haproxy — он и прост в настройке/установке, и ресурсы не жрет.

    Достаточно лишь сгенерить самоподписанный сертификат (прости, let‘s encrypt, но мы в траст-зоне)

    openssl genrsa -out dummy.key 1024
    openssl req -new -key dummy.key -out dummy.csr
    openssl x509 -req -days 3650 -in dummy.csr -signkey dummy.key -out dummy.crt
    cat dummy.crt dummy.key > dummy.pem

    и, собственно, настроить haproxy:

    frontend https-in
        mode tcp
        bind *:9243 ssl crt /etc/ssl/localcerts/dummy.pem alpn http/1.1
        http-response set-header Strict-Transport-Security "max-age=16000000; includeSubDomains; preload;"
        default_backend nodes-http
    
    backend nodes-http
        server node1 localhost:9200 check
    

    Теперь на порту 9243 будет прозрачная прокси до эластика (соответственно, меняем порт в регулярке и добавляем https).

    Однако и это не удовлетворит наш браузер, который печется о безопасности пользователя. На этот раз проблема в том, что нельзя запрашивать данные с другого домена, если он это не разрешил. Решается это с помощью механизма CORS. Хорошо хоть, что Elasticsearch это сам умеет:

    http.cors.allow-headers: X-Requested-With, Content-Type, Content-Length
    http.cors.allow-origin: "/.*/"
    http.cors.enabled: true
    

    Userscript


    Напомню, что мы все еще втирали делали эту дичь в формате букмарклета. В принципе, ничего страшного, но кому-то даже лишний раз кликнуть лень (например, мне). Поэтому будем делать userscript. Тут заодно встает проблема его обновления (делаем-то на века!). Поэтому воспользуемся механизмом обновления юзерскриптов кого я обманываю, конечно, очередным костылем:

    // ==UserScript==
    // @name     KIBANA_LOGS
    // @grant    none
    // @include  https://<rm-address>/*issues*
    // ==/UserScript==
    (function(){document.body.appendChild(document.createElement('script')).src='https://<kibana-address>:4443/kibana_logs_rm.js';})();

    Зато и в букмарклетах у параноиков будет обновляться. Для раздачи этой фигни нам понадобится https-сервер. Тут я уже откровенно заленился и взял первый попавшийся (да еще и на python 2.7) *посыпаю голову пеплом*:

    import BaseHTTPServer, SimpleHTTPServer
    import ssl
    
    httpd = BaseHTTPServer.HTTPServer(('0.0.0.0', 4443), 
                        SimpleHTTPServer.SimpleHTTPRequestHandler)
    httpd.socket = ssl.wrap_socket(httpd.socket, certfile='/etc/ssl/localcerts/dummy.pem',
                                server_side=True)
    httpd.serve_forever()

    Вот теперь пользователям осталось только создать юзерскрипт/букмарклет, добавить в исключения сертификат и все будет работать.

    Пара багов


    Суть первой проблемы заключается в следующем: когда нужно обработать результаты сразу нескольких ajax-запросов, в функцию передается столько аргументов, сколько было запросов. Но когда запрос один, jquery «любезно» его раскрывает в три аргумента. Поэтому пришлось писать такой костыль:

    let responses;
    if (requests.length == 1){
        responses = [arguments];
    } else {
        responses = Array.from(arguments);
    }

    Второй баг связан с тем, что при смене трекера или при смене статуса заявки Redmine сохраняет все введенные данные, запрашивает новый интерфейс (прямо html cо встроенным js), пересоздает интерфейс и перезаполняет поля с помощью функции replaceIssueFormWith. Звучит немного дико, но это сделано для реализации workflow (а там на разных стадиях поля для ввода потенциально могут отличаться). Тут тоже пришлось сделать костыль ad-hoc решение:

    function installReplaceHook(){
        let original = window.replaceIssueFormWith;
        window.replaceIssueFormWith = function(html){
            let logs_data = document.getElementById(data_id).value;
            let ret = original(html);
            createUi();
            document.getElementById(data_id).value = logs_data;
            return ret;
       };
    }

    Т.е. просто делаем хук на оригинальную функцию и делаем аналогичные ей действия для своего поля.

    Заключение


    Полную версию скрипта можно посмотреть в моем gist. Вот картинка, которую должно большинство ожидать к концу этой статьи:



    А вообще автоматизировать вещи — весело и полезно, и позволяет изучить что-то новое в другой области. Пользователи скрипта довольны, создание тикетов по логам в кибане теперь не так сильно напрягает.
    ИНФОРИОН
    59,00
    Решения ИТ-инфраструктуры и защита информации
    Поделиться публикацией

    Комментарии 6

      +1
      Есть еще вариант: написать плагин для кибаны, добавляющий кнопку «создать тикет в redmine»
        0

        Да, такой вариант я упустил. Но там будут те же проблемы, что и с вариантом №2 (надо будет дублировать часть функциональности RM) и потенциально могут быть проблемы с авторизацией.

          0
          Дернуть API RM не такая уж большая проблема.
            0

            Согласен, но заполнять информацию (описание, исполнитель, проект, категория и т.д.) где-то же надо? Если это в интерфейсе кибаны делать — придется копировать интерфейс создания тикета из RM. А если не заполнять — то появляется рутинная задача по исправлению всего этого дела для сгенерированных тикетов.

              +1
              Я бы сделал так: создал тике, потом редирект на RM, для дальнейшего заполнения
                0

                Да, хороший вариант. Отмечу, что тогда надо будет создать служебного пользователя для доступа только к нужным проектам, чтобы API с его авторизацией было. И из-за потенциально может потеряться информация, кто тикет создал.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое