Нередко в приложении со сложной бизнес-логикой требуется найти баг, который корнями уходит куда-то глубоко в DB-слой. Дело еще больше усложняется, когда в приложении нетривиальные SQL-запросы выражены через ORM или, что еще хуже, когда в системе построения запросов напрочь отсутствует какая-то структура. В такой ситуации на помощь может прийти proxy-сервер, который перехватит все запросы из приложения в БД и отобразит их в удобочитаемом виде. В этой статье мы рассмотрим те особенности протокола MySQL, которые помогут нам написать такой сервер.
 

Общая идея

Место proxy-сервера как раз между приложением, запросы которого нужно перехватить, и базой данных MySQL. То есть это будет обычный TCP-сервер, который слушает на заданном порту входящие соединения, читает из него данные, парсит их и далее пересылает уже в базу данных в точности в том виде, в каком они и пришли. Причем важно, чтобы proxy-сервер был асинхронным.

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

 

Инструментарий

Сегодня нам понадобится:

  1. MySQL-сервер.
  2. Приложение, которое шлет запросы к MySQL-серверу и запросы которого мы будем перехватывать.
  3. Wireshark, чтобы посмотреть, как устроен MySQL-пакет.
  4. Базовые знания Go.
 

Каркас приложения

Начнем с подготовки каркаса нашего proxy-сервера, который пока что будет работать просто посредником между приложением и базой данных, совершенно ничего не анализируя. Слушать он будет на локальном порту 3305, а пересылать пакеты на локальный порт 3306.

// Файл main.go

package main

import (
    "io"
    "log"
    "net"
)

const (
    MYSQL = "127.0.0.1:3306"
    PROXY = "127.0.0.1:3305"
)

func handleConnection(conn net.Conn) {
    defer conn.Close()

    mysql, err := net.Dial("tcp", MYSQL)
    if err != nil {
        log.Fatalf("%s: %s", "ERROR", err.Error())
        return
    }

    go io.Copy(conn, mysql)
    io.Copy(mysql, conn)
}

func main() {
    proxy, err := net.Listen("tcp", PROXY)
    if err != nil {
        log.Fatalf("%s: %s", "ERROR", err.Error())
    }
    defer proxy.Close()

    for {
        conn, err := proxy.Accept()
        if err != nil {
            log.Printf("%s: %s", "ERROR", err.Error())
        }

        go handleConnection(conn)
    }
}

Подробно рассмотрим, что здесь происходит. Как ты уже, вероятно, знаешь, точка входа — это функция main. Строка

proxy, err := net.Listen("tcp", PROXY)

инициирует начало прослушки на порту 3305. Важно не забыть закрыть proxy перед выходом из main:

defer proxy.Close()

Так как сервер должен постоянно принимать входящие соединения, запускаем вечный цикл, в котором и ожидаем подключения:

for {
    conn, err := proxy.Accept()
    //...
    go handleConnection(conn)
}

Здесь очень важно отметить, что вызов proxy.Accept() блокирующий. Это означает, что дальше этой строчки кода программа выполняться не будет, пока на порт 3305 не поступит соединение.

Далее начинается все самое интересное.

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

Содержимое метода handleConnection довольно простое и понятное, но тем не менее именно он — «сердце» приложения. Как только приложение коннектится к нашему proxy-серверу, происходит «дозвон» до MySQL и начинается обмен пакетами в асинхронном режиме:

go io.Copy(conn, mysql)
io.Copy(mysql, conn)

Все, что приходит в proxy-сервер из приложения, мы тут же пересылаем в MySQL, и наоборот. Это и есть проксирование, просто пока довольно бесполезное. Чтобы добиться решения наших задач, придется один из вызовов io.Copy заменить своей реализацией, которая будет выдирать запрос из пакета. Какой из этих двух io.Copy будем заменять? А тот, который копирует данные из приложения в MySQL. io.Copy имеет следующую сигнатуру:

func Copy(dst Writer, src Reader) (written int64, err error)

Значит, наш «клиент» — это второй вызов io.Copy, его в свое время и подменим. А перед тем как начать, нужно рассмотреть, как устроен MySQL-пакет, чтобы знать, как его парсить.

 

Основы MySQL-протокола

В MySQL предусмотрено несколько типов команд, на которых основывается весь протокол передачи данных, — такие как COM_QUERY, COM_DROP_DB, COM_PING, COM_STMT_PREPARE. Каждая команда имеет свою структуру и определенный список возможных ответов. Например, когда приложение решает разорвать соединение с MySQL-сервером, драйвер, который реализует протокол MySQL, посылает команду COM_QUIT, а в ответ может получить OK_Packet. Если верить официальной документации MySQL Internals Manual, то каждый пакет должен быть сформирован по следующим правилам:

  1. Размер пакета не должен превышать 16 Мбайт.
  2. Каждый пакет должен иметь так называемый packet header, который, в свою очередь, состоит из payload_length (3 байта) — длина тела пакета, в байтах, и sequence_id (1 байт) — номер пакета в последовательности.
  3. Каждый пакет должен включать в себя payload, то есть тело пакета, содержащее всю полезную информацию.

Схематично MySQL-пакет можно представить так:

payload_length   sequence_id   payload
[3 байта     ]   [1 байт   ]   [n байт]

Нам будут интересны только команды COM_QUERY и COM_STMT_PREPARE, так как они и содержат в себе SQL-запросы, которые хочется достать.

Пришло время выпустить монстра Wireshark и посмотреть вживую, из чего состоят эти команды. Учитывая, что приложение, запросы которого будем смотреть, и MySQL-сервер находятся на локальной машине, в списке интерфейсов Wireshark нужно выбрать что-то вроде Loopback: lo0:

Начальная страница Wireshark
Начальная страница Wireshark

Выбрав интерфейс, попадаем в окно, где будет видно весь нужный трафик. Пришло время отослать несколько SQL-запросов к серверу и посмотреть, что же получается. Пишем запрос и выполняем:

SELECT version();

В окне Wireshark сразу же станет видно весь трафик между приложением и MySQL-сервером. Для удобства выставим фильтр mysql.query, чтобы видеть только те пакеты, которые соответствуют командам COM_QUERY и COM_STMT_PREPARE или, иначе говоря, которые содержат строку SQL-запроса.

Страница трафика
Страница трафика

Выбрав любую строку и развернув список ▶ MySQL Protocol, можно наблюдать всю ту структуру пакета, о которой написано выше:

Структура пакета
Структура пакета

Красным выделено поле Packet Length — то же самое, что payload_length, его размер 3 байта, а равно оно значению 17. Зеленым на рисунке выделены эти 17 байт. Как видно, тело пакета СOM_QUERY состоит из частей Command и Statement. Command — это как раз индикатор команды, в данном случае СOM_QUERY. А Statement — это то, ради чего мы здесь. Команда COM_STMT_PREPARE имеет аналогичное строение пакета. Вот, собственно, и все, что потребуется. Значит, чтобы распарсить такой пакет, нужно предпринять следующие шаги:

  1. Получить заголовок пакета (первые 4 байта).
  2. При помощи заголовка посчитать длину тела пакета (в данном случае это 17 байт).
  3. Получить SQL-запрос длиной 16 байт (1 байт приходится на Command).

Приступим.

Продолжение статьи доступно только подписчикам

Вариант 1. Оформи подписку на «Хакер», чтобы читать все статьи на сайте

Подписка позволит тебе в течение указанного срока читать ВСЕ платные материалы сайта, включая эту статью. Мы принимаем оплату банковскими картами, электронными деньгами и переводами со счетов мобильных операторов. Подробнее о подписке

Вариант 2. Купи одну статью

Заинтересовала статья, но нет возможности оплатить подписку? Тогда этот вариант для тебя! Обрати внимание: этот способ покупки доступен только для статей, опубликованных более двух месяцев назад.


Комментарии

Подпишитесь на ][, чтобы участвовать в обсуждении

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

Check Also

Windows 10 против шифровальщиков. Как устроена защита в обновленной Windows 10

Этой осенью Windows 10 обновилась до версии 1709 с кодовым названием Fall Creators Update …