Движок DPI на C: от захвата пакетов до классификации протоколов.
Мне было интересно узнать, какие байты бегают по моей домашней сети. Я не нашёл простого инструмента, который можно было бы собрать за вечер и понять от начала до конца — и пришлось написать свой, на C. Эта статья — рассказ о том, как работает Deep Packet Inspection.
Код проекта: gitflic.ru/wirewalk/tiny-dpi-engine
Эпиграфы к разделам — цитаты из мультика «Шрек». (Не то чтобы я был фанат, но история уж очень подходящая. Если у вас есть идея что использовать в качестве источника цитат для следующей статьи — я открыт к предложениям!).
Что такое DPI и зачем он нужен
- Там так темно и страшно! - Это просто поле. Ничего страшного.
Когда вы открываете сайт, ваш компьютер отправляет и получает сотни сетевых пакетов в секунду. Каждый пакет — это конверт с адресами отправителя и получателя на обложке (IP-адреса, порты) и содержимым внутри.
Payload (полезная нагрузка) — это «содержимое конверта». Заголовки (адреса, порты) — это «надписи на конверте».
Обычный роутер или firewall смотрит только на надписи: «порт 80 — пропускаем, порт 25 — блокируем». Но что если кто-то крутит торренты через порт 443 (обычно занят HTTPS)? По конверту не отличишь — оба выглядят одинаково.
Deep Packet Inspection (DPI, «глубокая инспекция пакетов») — это когда мы вскрываем конверт и смотрим, что внутри. По содержимому пакета определяем реальный протокол, независимо от порта.
Где это применяется:
- сами-знаете-где
- Корпоративные сети: ограничить торренты и стриминг, не трогая рабочие сервисы
- Операторы связи: приоритизировать голос (VoIP) над скачиванием файлов
- Системы безопасности (IDS/IPS): обнаружить вторжения по сигнатурам
- Мониторинг: статистика — какой процент трафика занимает видео, какой — DNS
Существуют зрелые решения: nDPI, L7-filter, libprotoident. Зачем писать своё? Чтобы понять, как оно работает. Исследовательский проект — лучший способ разобраться в вопросе.
Архитектура: как устроен движок
- Окей, э-э… Начнём с начала? - Да, начнём с начала.
Наш DPI-движок — это конвейер. Представьте заводскую ленту: на вход подаётся сырой сетевой пакет, он проходит через несколько стадий обработки, на выходе — «этикетка» с названием определённого протокола.
┌─────────────┐ ┌───────────┐ ┌────────────────┐ ┌──────────────┐
│ Захват │───>│ Разбор │───>│ Классификация │───>│ Таблица │
│ (libpcap) │ │ пакетов │ │ протоколов │ │ потоков │
└─────────────┘ └───────────┘ └────────────────┘ └──────────────┘
Четыре стадии:
- Захват — получаем сырые байты из сети.
- Разбор — вскрываем «конверт»: извлекаем IP-адреса, порты, payload.
- Классификация — определяем протокол по содержимому и номерам портов.
- Таблица потоков — запоминаем результат, чтобы не повторять анализ.
Зачем четвёртая стадия? Представьте: вы общаетесь с сайтом. Это диалог — десятки пакетов туда-сюда. Если мы определили протокол по первому пакету, незачем повторять анализ для каждого следующего.
Поток (flow) — это набор пакетов одного соединения, идентифицируемый по пяти полям: src_ip, dst_ip, src_port, dst_port, ip_proto. Почему именно пять — поговорим позже, когда дойдём до таблицы потоков. Главное: первый пакет потока анализируется полностью, остальные наследуют результат. Это называется stateful-подход («подход с сохранением состояния»).
Захват пакетов: как подслушать сеть
- Э-э… мы точно туда идём? - Конечно!… Наверное.
Ваш компьютер непрерывно отправляет и получает сетевые пакеты. Обычно приложение видит только «свои» — браузер видит HTTP, мессенджер видит свои сообщения. Но для DPI нужно видеть все пакеты, проходящие через сетевую карту.
Для этого существует promiscuous mode («неразборчивый режим»). Обычно сетевая карта игнорирует пакеты, адресованные не ей. В promiscuous mode она передаёт ОС всё, что «слышит» — и чужие пакеты тоже.
libpcap — библиотека, которая даёт программе доступ к этому потоку пакетов. Она работает на Linux, macOS, *BSD, скрывая различия между ОС.
Для захвата нужны права root (
sudo) — ограничение безопасности ОС. (Не каждому пользователю дана возможность читать чужой трафик).
pcap_t *handle = pcap_open_live("eth0", 65535, 1, 1000, errbuf);
Параметры: интерфейс ("eth0" — имя сетевой карты), максимальный размер захвата (65535 — побольше, чтобы не
обрезать), promiscuous mode (1 = включить), таймаут (1000 мс).
Когда libpcap перехватывает пакет, она вызывает нашу функцию — callback. Callback — это функция, которую вы регистрируете заранее, а библиотека вызывает сама при наступлении события.
void callback(unsigned char *user, const struct pcap_pkthdr *h,
const unsigned char *bytes) {
dpi_parsed_packet_t pkt;
dpi_parse_packet(bytes, h->caplen, &pkt); // разбираем пакет
dpi_classify(ac, &pkt, flow_table, stats); // классифицируем
}
bytes — сырые байты перехваченного кадра. h->caplen — сколько байт реально захвачено.
Почему libpcap, а не работа с сетевой картой напрямую?
На Linux для этого используется AF_PACKET, на macOS — BPF, на FreeBSD — /dev/bpf. libpcap скрывает различия — один код работает везде.
Кроме того, эта библиотека умеет читать сохранённые дампы трафика из pcap-файлов — удобно для отладки.
Как выглядит реальный пакет
- Огры — они как лук! У них много слоёв! - Вы знаете, не все любят лук!
Прежде чем писать код парсинга, давайте посмотрим, что именно мы разбираем. Вот упрощённый hex-дамп реального HTTP-запроса:
ff ff ff ff ff ff 00 11 22 33 44 55 08 00
^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^ ^^^^^
dst MAC (6 байт) src MAC (6 байт) ethertype: 0x0800 = IPv4
<- 14 байт = Ethernet-заголовок ->
45 00 00 2e 00 01 00 00 40 06 ?? ?? c0 a8 01 01 c0 a8 01 02
^ ^ ^ ^ ^^^^^^^^^^^ ^^^^^^^^^^^
ver=4, длина TTL, TCP src: dst:
IHL=5 (20 б) всего proto=6 192.168.1.1 192.168.1.2
<- 20 байт = IPv4-заголовок ->
04 01 00 50 00 00 00 01 00 00 00 00 50 02 ?? ?? ?? ??
^^^^ ^^^^ ^^^^^^^^
src: dst: seq data offset=5 window,
49121 80 (20 байт) checksum
<- 20 байт = TCP-заголовок ->
47 45 54 20 2f 69 6e 64 65 78 2e 68 74 6d 6c 20 48 54 54 50 ...
G E T _ / i n d e x . h t m l _ H T T P ...
<- payload: "GET /index.html HTTP..." ->
Видно структуру: три заголовка (Ethernet, IP, TCP) - и payload. Сетевой пакет - это «матрёшка»: каждый слой оборачивает следующий.
| Ethernet (14 байт) | IPv4 (20+ байт) | TCP (20+ байт) | Payload |
Кстати, создатели интернета тоже любили лук аналогию со слоями — модель OSI это как раз таки 7 слоёв. Но нам для практического DPI хватит четырёх.
Теперь будем разбирать эту матрёшку программно.
Разбор пакетов: снимаем слои матрёшки
- Это что, настоящая кровь? - Ну… это не НАША кровь.
Порядок байтов: почему всё «перевёрнуто»
Прежде чем читать заголовки — одна важная деталь. В компьютерах многобайтовые числа хранятся в определённом порядке:
- Little-endian (x86, ARM): младший байт первым. Число
0x1234-> в памяти34 12 - Big-endian (сетевой порядок): старший байт первым. То же число ->
12 34
Исторически сетевые протоколы используют big-endian — это называется network byte order. А ваш компьютер,
скорее всего, little-endian. Поэтому при чтении чисел из заголовков нужно «переворачивать» байты. Функция
ntohs() — network to host short — делает именно это:
uint16_t ethertype = ntohs(out->eth->ethertype); // "переворачиваем" 2 байта
Забыли ntohs — прочитаете 0x0800 (IPv4) как 0x0008 и ничего не поймёте. Это классические грабли, на которые
наступал каждый сетевой программист.
Ethernet -> IP
int dpi_parse_packet(const uint8_t *raw, size_t raw_len,
dpi_parsed_packet_t *out) {
const uint8_t *ptr = raw; // текущая позиция в буфере
const uint8_t *end = raw + raw_len; // граница буфера
/* Ethernet: 14 байт - dst_mac(6) + src_mac(6) + ethertype(2) */
if (raw_len < 14) return -1; // слишком короткий - отбрасываем
out->eth = (const dpi_eth_header_t *)ptr;
uint16_t ethertype = ntohs(out->eth->ethertype);
ptr += 14; // сдвигаем указатель на 14 байт
Мы берём указатель на начало данных и кастуем его к структуре заголовка. Cast (приведение типа) — это когда мы говорим компилятору: «интерпретируй эти байты как структуру с такими-то полями». Никакого копирования — просто смотрим на те же байты «через другие очки».
Поле ethertype (2 байта) говорит, что внутри:
0x0800— IPv4 (то что нам нужно)0x86DD— IPv6 (не поддерживаем)0x8100— VLAN-тег (об этом ниже)
Если не IPv4 — пропускаем пакет.
VLAN: когда одна сеть делится на несколько
Иногда один физический кабель (или Wi-Fi) используется для нескольких виртуальных сетей — VLAN (Virtual LAN). В таком случае в кадр добавляется 4 байта — «бирка» с номером виртуальной сети:
| Ethernet (12 байт) | VLAN-тег (4 байта) | реальный ethertype | данные... |
Проверяем: если ethertype = 0x8100 — дальше VLAN-тег, пропускаем 4 байта и читаем настоящий ethertype:
if (ethertype == 0x8100) {
if (ptr + 4 > end) return -1;
ethertype = read_u16_be(ptr + 2);
ptr += 4;
}
IP-заголовок
out->ip = (const dpi_ip_header_t *)ptr;
uint8_t ihl = (out->ip->version_ihl & 0x0F) * 4;
Здесь появляется побитовая операция. Разберём пошагово.
Поле version_ihl — один байт, в котором «упакованы» два значения:
биты: 7 6 5 4 | 3 2 1 0
версия IP | IHL
(= 4) | (= 5 обычно)
Нам нужно «вырезать» младшие 4 бита. Оператор & (побитовое И) с маской 0x0F (0000 1111) делает именно
это — оставляет только младшие 4 бита, обнуляя старшие:
version_ihl = 0x45 = 0100 0101
mask = 0x0F = 0000 1111
результат = 0x05 = 0000 0101 <- IHL = 5
Умножение на 4 переводит из «32-битных слов» в байты: IHL=5 -> 20 байт. Обычно так и есть, но с опциями IP — до 60.
TCP: заголовок переменной длины
TCP сложнее UDP: заголовок — от 20 до 60 байт. Длина хранится в поле Data Offset — старшие 4 бита
data_offset_flags:
uint16_t tcp_hdr_len = ((ntohs(tcp->data_offset_flags) >> 12) & 0xF) * 4;
Разберём эту строку:
ntohs(...)— переворачиваем байты (как обсуждали выше)>> 12— побитовый сдвиг вправо на 12 позиций. Аналогия: в числе 12345 убрать три последние цифры — получится 12. Здесь то же самое, но с битами: «выталкиваем» 12 младших бит, оставляя только 4 старших.& 0xF— маска, оставляет только эти 4 бита* 4— переводим из слов в байты
Payload начинается сразу после заголовка TCP/UDP:
out->payload = ptr; // указатель на начало payload
out->payload_len = (size_t)(ip_end - ptr); // байт до конца IP-пакета
Zero-copy: почему мы не копируем данные
Заметьте: мы не создаём копии пакетов. Все указатели (out->eth, out->ip, out->payload) ссылаются на
оригинальный буфер libpcap. Это называется zero-copy.
При скорости 10 Гбит/с через сетевую карту проходит ~1.5 млн пакетов/сек. Если бы мы решили копировать каждый —
процессор потратил бы больше времени на memcpy, чем на анализ. А так — просто «смотрим» на байты через указатели, дешево и сердито.
Проверки границ
Каждый шаг: if (ptr + 20 > end) return -1;. Если байтов не хватает — пакет повреждён, пропускаем.
В реальной сети бывают «битые» пакеты и специально созданные (от злоумышленников). Строгие проверки это
защита от уязвимостей.
Классификация протоколов: три линии обороны
- Откуда ты знаешь, что он - огр? - Он сам сказал! - А если соврал?
Итак, мы извлекли payload из пакета. Что с ним делать? Искать характерные паттерны — сигнатуры протоколов.
Сигнатура — характерная последовательность байтов, по которой можно опознать протокол:
| Протокол | Сигнатура | Человекочитаемо |
|---|---|---|
| HTTP | 47 45 54 20 | "GET " |
| SSH | 53 53 48 2d | "SSH-" |
| TLS | 16 03 01 | байт 0x16 + версия |
| SIP | 49 4e 56 49 54 45 20 | "INVITE " |
Но сигнатуры — не единственный способ. Мы используем трёхстадийную стратегию:
Пакет -> Поток уже классифицирован?
├─ Да -> наследуем (быстро!)
└─ Нет ->
├─ Стадия 1: Инспекция payload
│ Смотрим на байты и структуру заголовка:
│ "GET " -> HTTP, 0x16 0x03 -> TLS,
│ version=2 + PT=0..34 -> RTP
│
├─ Стадия 2: Ахо-Корасик
│ Пользовательские сигнатуры из файла
│
└─ Стадия 3: Фолбэк на порты
80 -> HTTP, 443 -> HTTPS, 53 -> DNS, 22 -> SSH...
Почему три стадии, а не только сигнатуры?
Зашифрованный TLS на порту 443 не содержит "GET " — зашифрован. Но по порту мы знаем: это HTTPS. RTP на
случайном порту — распознаётся по структуре заголовка, а не по сигнатуре. А HTTP на нестандартном порту 8080 —
ловится сигнатурой "GET ".
Три метода дополняют друг друга. Самый интересный из них — алгоритм поиска сигнатур. Разберём его подробно.
Ахо-Корасик: поиск 50 сигнатур за один проход
- Я знаю, о чём ты думаешь! - …Пончик? - НЕТ! Хотя… да, пончик.
Наивный подход: для каждой сигнатуры проходим по payload. 50 сигнатур × 1400 байт = 70 000 сравнений на пакет. Многовато.
Алгоритм Ахо-Корасик ищет все сигнатуры за один проход по payload. Два ингредиента: trie-дерево и failure-ссылки.
Trie-дерево
Trie — дерево, где каждый путь от корня к листу — одна сигнатура. Каждый узел — развилка по очередному байту.
Пример для двух сигнатур: "abc" и "bc":
root
/ \
a b
| |
b c [bc!]
|
c [abc!]
Сканируем "abc": root -> a -> a->b -> a->b->c -> нашли «abc»! Всё просто — идём по дереву.
А если "xbc"? x — нет перехода из root, стоим на месте. b — есть переход из root, идём вправо. c — идём в b->c -> нашли «bc»!
Пока всё тривиально. Проблема появляется дальше.
Failure-ссылки
Только что мы прошлись по дереву без проблем: "abc" -> нашли «abc», "xbc" -> нашли «bc».
А вот "abx". Пошли: a (в левую ветку), b (дальше), x — а перехода x из узла a->b нет. Застряли.
Без хитростей — возвращаемся в root и начинаем с x. Но мы уже прошли b! И опять начинать сначала?!
Failure-ссылка из узла a->b ведёт не в root, а прямо в узел b под корнем:
root
/ \
a b <--- failure (a->b) указывает сюда
| |
b ------
|
c
Теперь сканируем "abx": a -> a->b -> застряли. Failure перебрасывает нас в b под корнем.
Следующий байт x проверяем уже оттуда. Не совпало - стоим. (Но если бы вместо x был c —
мы бы сразу нашли «bc», потому что уже стояли в нужном месте.)
Суть: failure-ссылка говорит «вернись туда, где ты потерялся». Результат — один проход по payload, O(N), без возвратов.
Код: построение failure-ссылок
Failure-ссылки строятся один раз при загрузке базы сигнатур, до начала захвата пакетов. Алгоритм — обход в ширину (BFS), уровень за уровнем:
// Уровень 1: дети корня - их failure = root
for (int c = 0; c < 256; c++) {
if (root->children[c]) {
root->children[c]->fail = root;
} else {
root->children[c] = root;
}
}
// Уровни 2, 3, ...: для каждого узла ищем failure
while (head < tail) {
dpi_ac_node_t *node = queue[head++];
for (int c = 0; c < 256; c++) {
if (node->children[c]) {
dpi_ac_node_t *f = node->fail;
while (f != root && !f->children[c])
f = f->fail;
node->children[c]->fail = f->children[c] ?: root;
queue[tail++] = node->children[c];
}
}
}
Оптимизация root->children[c] = root для пустых переходов — хитрый трюк. Без неё при каждом несовпадении нужно
проверять: «а не в корне ли мы? а есть ли переход?» С ней — cur->children[c] всегда не-NULL, поиск
становится проще и быстрее.
Код: поиск
int dpi_ac_search(const dpi_ac_t *ac, const uint8_t *data, size_t len) {
dpi_ac_node_t *cur = ac->root;
for (size_t i = 0; i < len; i++) {
while (cur != root && !cur->children[data[i]])
cur = cur->fail;
if (cur->children[data[i]])
cur = cur->children[data[i]];
if (cur->sig_id >= 0)
return cur->sig_id;
}
return -1;
}
Один цикл по payload. 5, 50 или 5000 сигнатур — скорость одна и та же.
Пул аллокаций
Узлы выделяются из одного непрерывного массива — пула. Зачем?
Процессор читает память не по одному байту, а кэш-линиями по 64 байта. Если узлы разбросаны по памяти
(каждый malloc — где придётся) — процессор постоянно «промахивается» мимо кэша и ждёт данные из оперативной
памяти. В 10-100 раз медленнее.
Если узлы лежат рядом в одном массиве — следующий узел, скорее всего, уже в кэше. Это называется кэш-локальность — «данные рядом -> доступ быстрый».
Бонус: освобождение памяти — один free(pool) вместо обхода всего дерева.
RTP: протокол без текстовой сигнатуры
- Ты же говорил, что огры не похожи на торты! - Говорил. Но этот - похож.
Прежде чем перейти к таблице потоков — один интересный пример классификации.
RTP (Real-time Transport Protocol) используется для голоса и видео в реальном времени: звонки,
видеоконференции. У него нет текстовой сигнатуры — нельзя искать "RTP" в payload. Но есть строгая
структура заголовка:
if (payload_len >= 12) {
int version = (payload[0] >> 6) & 0x03; // Старшие 2 бита
int pt = payload[1] & 0x7F; // Младшие 7 бит второго байта
- version должен быть 2 (всегда, для современного RTP)
- pt (Payload Type): 0..34 - аудио/видео кодеки (PCMU, GSM, Opus…), 200..207 - пакеты управления (RTCP)
if (version == 2 && pt <= 34)
return DPI_PROTO_RTP; // Голос или видео
if (version == 2 && pt >= 200 && pt <= 207)
return DPI_PROTO_RTCP; // Управление: статистика, завершение
}
RTP работает на динамических портах — номер порта назначается случайно при установке звонка. Поэтому порт не поможет — только структура заголовка. Это хороший пример того, почему нужны разные методы классификации.
Таблица потоков: запоминаем, что уже знаем
- Подождите, вы это серьёзно? Вы помните ВСЁ, что я говорил?! - У меня хорошая память.
Мы научились классифицировать один пакет. Но пакетов тысячи — как не повторять работу?
Что такое «поток»
Поток (flow) — набор пакетов одного соединения. Идентифицируется 5-кортежем (5-tuple):
- src_ip — IP-адрес отправителя
- dst_ip — IP-адрес получателя
- src_port — порт отправителя
- dst_port — порт получателя
- ip_proto — протокол (TCP = 6, UDP = 17)
Почему именно 5? Меньше — недостаточно: два соединения на один IP/порт будут неотличимы. Больше — избыточно: этих пяти достаточно для однозначной идентификации.
Канонизация: A->B = B->A
В диалоге клиента с сервером пакеты идут в обе стороны. Запрос — A->B, ответ — B->A. Формально 5-кортежи разные — IP и порты переставлены. Но это один диалог!
Решение: канонизация — всегда приводим к стандартному виду. (Пусть безобразно — зато однообразно!). IP-адреса сравниваем как числа, меньшее — на первое место:
if (ntohl(src_ip) > ntohl(dst_ip)) {
// Меняем местами src и dst
}
Теперь A->B и B->A -> одинаковый ключ -> одна запись.
Хэш-таблица
Для быстрого поиска потоков используем хэш-таблицу. Идея: из ключа (5-кортежа) вычисляем число — хэш - и используем как индекс в массиве.
static uint32_t flow_hash(const dpi_5tuple_t *t) {
uint32_t h = t->src_ip;
h ^= t->dst_ip;
h ^= (t->src_port << 16) | t->dst_port;
h ^= t->ip_proto;
// Перемешивание (Murmur-like finalizer)
h = ((h >> 16) ^ h) * 0x45d9f3b;
h = ((h >> 16) ^ h) * 0x45d9f3b;
h = (h >> 16) ^ h;
return h % DPI_FLOW_TABLE_SIZE;
}
Хэш-функция «перемешивает» биты, чтобы записи распределялись равномерно. Без перемешивания похожие IP-адреса скучивались бы в одних ячейках.
Размер таблицы — 65537. Почему не «круглое» 65536? Потому что 65537 — простое число. Когда размер таблицы - степень двойки, младшие биты хэша полностью определяют ячейку, что может вызывать скучивание при определённых закономерностях в данных. Простое число устраняет этот эффект. (Классический трюк, который нам подарил дедушка программирования Дональд Кнут).
Коллизии (два разных ключа -> один хэш) неизбежны — нельзя отобразить бесконечное число ключей в 65537 ячеек без повторений. Коллизию разрешаем цепочками: каждая ячейка — связный список. Если возникает коллизия — просто добавляем значение в список.
Как добавить свой протокол
- Можно мне тоже? - Конечно! Инструкция простая…
Классификатор легко расширить. Пример: добавим MQTT — лёгкий протокол для «интернета вещей»
(порт 1883, стартовый байт 0x10).
1. Enum в заголовочном файле:
DPI_PROTO_MQTT,
2. Имя для отображения:
{DPI_PROTO_MQTT, "MQTT"},
3. Порт — фолбэк:
if (dst == 1883 || src == 1883) return DPI_PROTO_MQTT;
4. Payload-эвристика:
if (payload_len >= 2 && (payload[0] & 0xF0) == 0x10)
return DPI_PROTO_MQTT;
5. Сигнатура в текстовом файле:
MQTT: \x10
Пересобрали — и MQTT-трафик распознаётся. Ахо-Корасик автоматически перестроит trie по обновлённой базе.
Результаты
- Ну и как? Получилось? - Более-менее. - «Более-менее» — это хорошо или плохо?
Тестирование на реальном Wi-Fi-трафике (1000 пакетов, бытовая сеть):
=== DPI Statistics ===
Total packets: 987
Total bytes: 816543
Classified: 944
Unclassified: 43
Active flows: 49
Classification: 95.6%
Per-protocol breakdown:
HTTP 16
HTTPS 898
DNS 20
TLS 10
95.6% пакетов классифицировано. Для сравнения: коммерческие системы (nDPI, PACE) дают 97-99%. Наш результат неплох для учебного проекта с ~500 строками кода — основная «потеря» приходится на зашифрованный трафик на нестандартных портах.
HTTPS доминирует — ожидаемо для современного интернета. HTTP — единичные запросы. DNS — системные запросы на преобразование имён.
43 неклассифицированных пакета — зашифрованный трафик на нестандартных портах (мессенджеры, VPN) и ICMP (ping).
Пример таблицы потоков (IP-адреса заменены):
SRC IP PORT -> DST IP PORT PROTO PACKETS BYTES
10.0.0.1 53 -> 192.168.1.100 46116 DNS 2 221
93.184.216.34 443 -> 192.168.1.100 49526 HTTPS 7 343
93.184.216.34 80 -> 192.168.1.100 42986 HTTP 12 2207
140.82.121.4 443 -> 192.168.1.100 49054 HTTPS 28 14127
198.51.100.1 9000 -> 192.168.1.100 36768 TLS 13 5207
Обратите внимание: поток на порту 9000 классифицирован как TLS, а не HTTP. Порт 9000 обычно не связан с TLS —
но инспекция payload обнаружила байты 0x16 0x03 0x01 (TLS Handshake) и правильно определила протокол. Это
именно то, что DPI делает лучше обычного firewall’а.
Формат сигнатур
База сигнатур — текстовый файл. Одна строка — одна сигнатура:
# HTTP
HTTP: GET
HTTP: POST
HTTP: HTTP/1.0
# SSH
SSH: SSH-2.0
SSH: SSH-1.99
# TLS (бинарные байты через \xHH)
TLS: \x16\x03\x01
TLS: \x16\x03\x02
# SIP
SIP: INVITE
SIP: BYE
SIP: SIP/2.0
Формат: ПРОТОКОЛ: шаблон. Текстовые паттерны — как есть, бинарные — через \xHH. Добавить сигнатуру —
одну строку в файле, перекомпилировать не нужно.
Ограничения
- Это всё? - Это МОГЛО БЫТЬ всё.
Проект сознательно не включает:
- IPv6 — другая структура заголовка (40 байт фиксированной длины, нет IHL)
- IP-дефрагментацию — фрагментированные пакеты анализируются отдельно; только первый фрагмент содержит TCP/UDP-заголовок
- TCP reassembly — сегменты не склеиваются; HTTP-заголовок, разбитый на два сегмента, может быть не распознан
- Шифрованный трафик — зашифрованный TLS непрозрачен; определяем по заголовку, но не анализируем содержимое
- ML-классификация — промышленные DPI используют машинное обучение (размер пакетов, тайминги); у нас только сигнатуры и порты
Каждый из этих пунктов — тема для отдельной статьи. Или для вашего pull request’а.
Что дальше
- И что теперь? - А вот это уже совсем другая история.
Проект открыт: gitflic.ru/wirewalk/tiny-dpi-engine. Зеркало: github.com/wirewalk/tiny-dpi-engine. Pull request’ы приветствуются.
Возможные направления:
- IPv6 — расширение парсера
- TCP reassembly — склеивание сегментов
- JSON-вывод — для интеграции с системами мониторинга
- REST API — статистика в реальном времени
- Бенчмарки — сравнение с nDPI по производительности
Спасибо за ваше внимание к этому вопросу (C). Если статья была полезна — лайк, подписка, комментарий. Вопросы, замечания, идеи? — Круто, буду рад вас услышать.




