Книга: BPF для мониторинга Linux
Назад: 6. Сетевое взаимодействие в Linux и BPF
Дальше: 8. Безопасность ядра Linux, его возможности и Seccomp

7. Express Data Path

Express Data Path (XDP) — это безопасный, программируемый, высокопроизводительный процессор пакетов, интегрированный в ядро, который используется при прохождении сетевых данных в Linux. Выполняет программы BPF по получении пакета драйвером NIC. Это позволяет программам XDP в кратчайшие сроки принять решение по поводу данного пакета — отбросить, изменить или просто разрешить его.

Точка выполнения — это не единственное, за счет чего программы XDP работают быстро, определенную роль играют в этом и другие решения, реализованные в ходе разработки.

При обработке пакетов с помощью XDP не нужно выделять память.

• Программы XDP работают только с линейными нефрагментированными пакетами и задействуют начальный и конечный указатели пакета.

• Нет доступа к полным метаданным пакета, поэтому входной контекст, который получает этот тип программы, будет иметь тип xdp_buff вместо структуры sk_buff, с которой вы познакомились в главе 6.

• Являясь программами eBPF, программы XDP ограничены по времени выполнения, и, как следствие, их использование имеет фиксированную стоимость в сетевом конвейере.

Говоря о XDP, важно помнить, что это не механизм обхода ядра — он предназначен для интеграции с другими компонентами ядра и внутренней моделью безопасности Linux.

7105.png

Структура xdp_buff применяется для представления контекста пакета программе BPF, которая использует механизм прямого доступа к пакету, обеспечиваемый платформой XDP. Считайте это облегченной версией sk_buff.

Разница между ними заключается в том, что sk_buff поддерживает также метаданные пакетов (proto, mark, type), которые доступны только на более высоком уровне в сетевом конвейере, и позволяет смешиваться с ними. То, что xdp_buff создается рано и не зависит от других уровней ядра, является одной из причин, по которым он быстрее получает и обрабатывает пакеты с помощью XDP. Другая причина заключается в том, что xdp_buff не содержит ссылок на маршруты, ловушек управления трафиком или других метаданных пакета, как происходит в программах, использующих sk_buff.

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

Обзор программ XDP

По сути, что делают программы XDP? Они решают, как поступить с принятым пакетом, а затем могут редактировать его содержимое или просто возвращать код результата. Этот код используется для определения того, что происходит с пакетом. Вы можете отбросить пакет, передать его через тот же интерфейс или переслать остальному сетевому стеку. Кроме того, для взаимодействия с сетевым стеком программы XDP могут вставлять и извлекать заголовки пакета. Например, если текущее ядро не поддерживает формат инкапсуляции или протокол, программа XDP может деинкапсулировать его или преобразовать протокол и отправить результат в ядро для обработки.

Но подождите, какова связь между XDP и eBPF?

Оказывается, программы XDP управляются через системный вызов bpf и загружаются с использованием типа программы BPF_PROG_TYPE_XDP. Кроме того, ловушка драйвера выполнения выполняет байт-код BPF.

При написании XDP-программ важно понимать, что контексты, в которых они будут выполняться, называются также режимами работы.

Режимы работы

У XDP есть три режима работы: для удобного тестирования функций, на заказном оборудовании от поставщиков и на обычным образом скомпилированных ядрах без специального оборудования. Рассмотрим их.

Нативный XDP

Такой режим задан по умолчанию. В этом случае программа XDP BPF запускается непосредственно в ходе раннего приема сетевого драйвера. При использовании этого режима важно проверить, поддерживает ли его драйвер. Вы можете сделать это, выполнив следующую команду для исходного дерева данной версии ядра:

# Клонировать стабильный репозиторий linux

git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/

linux-stable.git\

linux-stable

 

# Проверить тег для текущей версии вашего ядра

cd linux-stable

git checkout tags/v4.18

 

# Проверить доступные драйверы

git grep -l XDP_SETUP_PROG drivers/

Вывод будет подобен следующему:

drivers/net/ethernet/broadcom/bnxt/bnxt_xdp.c

drivers/net/ethernet/cavium/thunder/nicvf_main.c

drivers/net/ethernet/intel/i40e/i40e_main.c

drivers/net/ethernet/intel/ixgbe/ixgbe_main.c

drivers/net/ethernet/intel/ixgbevf/ixgbevf_main.c

drivers/net/ethernet/mellanox/mlx4/en_netdev.c

drivers/net/ethernet/mellanox/mlx5/core/en_main.c

drivers/net/ethernet/netronome/nfp/nfp_net_common.c

drivers/net/ethernet/qlogic/qede/qede_filter.c

drivers/net/netdevsim/bpf.c

drivers/net/tun.c

drivers/net/virtio_net.c

Итак, ядро 4.18 поддерживает:

сетевой драйвер Broadcom NetXtreme-C/E bnxt;

• драйвер Cavium thunderx;

• драйвер Intel i40;

• драйверы Intel ixgbe и ixgvevf;

• драйверы Mellanox mlx4 и mlx5;

• Netronome Network Flow Processor;

• QLogic qede NIC Driver;

• TUN/TAP;

• Virtio.

Получив четкое представление о режиме работы по умолчанию, приступим к рассмотрению того, как инструкции программы XDP могут напрямую обрабатываться сетевыми картами с использованием выгруженного XDP.

Выгруженный XDP

В этом режиме программа XDP BPF напрямую выгружается в NIC, а не выполняется на центральном процессоре хоста. Исключив выполнение из ЦП, этот режим получает выигрыш в производительности по сравнению с родным XDP.

Мы можем повторно применить дерево исходного кода ядра, которое только что клонировали, чтобы проверить, какие драйверы NIC в 4.18 поддерживают аппаратную разгрузку. Для этого поищем XDP_SETUP_PROG_HW:

git grep -l XDP_SETUP_PROG_HW drivers/

В выводе должно получиться что-то вроде этого:

include/linux/netdevice.h

866:    XDP_SETUP_PROG_HW,

 

net/core/dev.c

8001:           xdp.command = XDP_SETUP_PROG_HW;

 

drivers/net/netdevsim/bpf.c

200:    if (bpf->command == XDP_SETUP_PROG_HW && !ns->bpf_xdpoffload_accept) {

205:    if (bpf->command == XDP_SETUP_PROG_HW) {

560:    case XDP_SETUP_PROG_HW:

 

drivers/net/ethernet/netronome/nfp/nfp_net_common.c

3476:   case XDP_SETUP_PROG_HW:

Здесь видно, что только Netronome Network Flow Processor (nfp) поддерживает данный режим. Это значит, что он может работать в обоих режимах, поддерживая аппаратную разгрузку наряду с родным XDP.

Теперь такой вопрос: что мне делать, если у меня нет сетевых карт и драйверов, чтобы попробовать мои программы XDP? Ответ прост: общий XDP!

Общий XDP

Он предусмотрен для тестового режима разработки, когда нужно писать и запускать программы XDP, не имея возможностей родного или разгрузочного XDP. Общий XDP поддерживается с версии ядра 4.12. Вы можете использовать этот режим, например, на устройствах veth, а мы применим его в последующих примерах для демонстрации возможностей XDP, не требуя, чтобы вы покупали специальное оборудование.

Но кто координирует общее взаимодействие между всеми компонентами и режимами работы? В следующем разделе рассмотрим пакетный процессор.

Пакетный процессор

Процессор пакетов XDP позволяет выполнять программы BPF для пакетов XDP и координирует взаимодействие между ними и сетевым стеком. Он является компонентом ядра для программ XDP, который обрабатывает пакеты в очереди приема (RX) напрямую — так, как они представлены NIC. Это гарантирует, что пакеты доступны для чтения и записи, и позволяет выполнять постобработку в пакетном процессоре. Обновление атомарных программ и загрузка новых программ в процессор пакетов могут выполняться во время работы, не требуя прерывать обслуживание с точки зрения сети и связанного трафика. Во время работы XDP можно использовать в режиме занятого опроса, что позволяет зарезервировать процессоры, которые будут иметь дело со всеми очередями RX. Это позволяет избежать переключения контекста и обеспечивает обработку пакетов немедленно по прибытии, независимо от их соответствия IRQ. Другой режим, в котором можно задействовать XDP, — это режим, управляемый прерыванием. Он не резервирует ЦП, а выдает прерывание, действующее в среде событий, чтобы проинформировать ЦПУ о том, что ему, выполняя обычную обработку, нужно иметь дело с новым событием.

На рис. 7.1 вы видите точки взаимодействия между RX/TX, приложениями, процессором пакетов и программами BPF, применяемыми к пакетам.

7123.png 

Рис. 7.1. Пакетный процессор

Обратите внимание, что здесь есть несколько блоков со строкой, добавленной к XDP_. Это коды результата XDP, которые мы рассмотрим далее.

Коды результата XDP (действия процессора пакетов)

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

Отбросить (XDP_DROP). Отбрасывает пакет. Это происходит на самой ранней стадии приема в драйвере и означает просто его возврат в очередь RX, в которую он только что прибыл. Отбрасывание пакета как можно раньше помогает предотвратить отказ в обслуживании (DoS). Таким образом отброшенные пакеты используют как можно меньше процессорного времени и мощности процессора.

• Передать (XDP_TX). Пересылает пакет. Это может произойти до или после его изменения. Пересылка пакета подразумевает возврат страницы принятого пакета на тот сетевой адаптер, на котором она была получена.

• Перенаправить (XDP_REDIRECT). Подобно XDP_TX в том плане, что он может передавать пакет XDP, но делает это через другой сетевой адаптер или в cpumap BPF. В случае cpumap BPF ЦП, обслуживающие XDP в очередях приема NIC, могут продолжать работу и передавать пакет для обработки верхнего стека ядра на удаленный ЦП. Это похоже на XDP_PASS, но с возможностью того, что программа XDP BPF продолжит обслуживать входящую высокую нагрузку, а не станет тратить время на передачу текущего пакета на верхние уровни.

• Пропустить (XDP_PASS). Передает пакет в обычный сетевой стек для обработки. Эквивалентно поведению обработки пакетов по умолчанию без XDP. Это можно сделать одним из двух способов:

• обычный прием распределяет метаданные (sk_buff), принимает пакет в стек и направляет его другому процессору для обработки. Здесь допустимы сырые интерфейсы для пользовательского пространства. Может происходить до или после изменения пакета;

• общая разгрузка приема (GRO) может предусматривать прием больших пакетов и объединение пакетов одного и того же соединения. GRO в конечном итоге пропускает пакет через поток обычного приема после обработки.

• Код ошибки (XDP_ABORTED). Обозначает ошибку программы eBPF и приводит к удалению пакета. Это не то, что функциональная программа должна использовать в качестве кода возврата. Например, XDP_ABORTED будет возвращено, если программа делит что-либо на ноль. Значение XDP_ABORTED всегда равно нулю. При этом оказывается пройденной точка трассировки trace_xdp_exception, которую можно дополнительно исследовать для выявления неправильного поведения.

Эти коды действий записаны в заголовочном файле linux/bpf.h следующим образом:

enum xdp_action {

    XDP_ABORTED = 0,

    XDP_DROP,

    XDP_PASS,

    XDP_TX,

    XDP_REDIRECT,

};

Действия XDP определяют различное поведение и являются внутренним механизмом процессора пакетов. На рис. 7.2 приведена упрощенная, ориентированная только на возвратные действия версия того, что изображено на рис. 7.1.

7139.png 

Рис. 7.2. Коды действий XDP

Интересной особенностью XDP-программ является то, что для них обычно не нужно писать загрузчик. На большинстве машин Linux есть хороший загрузчик, реализованный командой ip. В следующем разделе расскажем, как его использовать.

XDP и iproute2 в качестве загрузчика

Команда ip, доступная в iproute2 (https://oreil.ly/65zuT), может выступать в качестве интерфейса для загрузки программ XDP, скомпилированных в формате ELF, и полностью поддерживает карты и их перемещение, вызов конечного оператора и фиксацию объекта.

Поскольку загрузка программы XDP может быть выражена как конфигурация существующего сетевого интерфейса, загрузчик реализован как часть команды iplink, которая выполняет настройку сетевого устройства.

Синтаксис для загрузки программы XDP прост:

# ip link set dev eth0 xdp obj program.o sec mysection

Проанализируем параметры этой команды по порядку:

ip — вызывает команду ip;

• link — настраивает сетевые интерфейсы;

• set — изменяет атрибуты устройства;

• deveth0 — определяет сетевое устройство, на котором мы хотим работать и куда загружать программу XDP;

• xdpobjprogram.o — загружает программу XDP из ELF-файла (объектный файл) с именем program.o. Часть xdp этой команды указывает системе использовать собственный драйвер, когда он доступен, в противном случае — общий. Вы можете принудительно задать один или другой режим, применив более специфический селектор:

• xdpgeneric для общего XDP;

• xdpdrv для родного XDP;

• xdpoffload для выгруженного XDP;

• secmysection — указывает имя секции mysection, содержащее программу BPF для использования из файла ELF. Если оно не задано, будет применяться раздел с именем prog. Если в программе не указан раздел, вы должны задать sec.text в вызове ip.

Рассмотрим практический пример.

У нас есть система с веб-сервером на порте 8000, для которой мы хотим заблокировать любой доступ к его страницам на общедоступной сетевой карте, запретив все TCP-подключения к ней.

Первое, что нам понадобится, — это рассматриваемый веб-сервер. Если у вас его еще нет, запустите его с помощью команды python3:

$ python3 -m http.server

После запуска веб-сервера порт, на котором он работает, будет показан в открытых сокетах с помощью ss. Веб-сервер привязан к любому интерфейсу, *:8000, поэтому на данный момент любой внешний абонент, имеющий доступ к нашим общедоступным интерфейсам, сможет видеть его содержимое!

$  ss -tulpn

Netid  State      Recv-Q Send-Q Local Address:Port   Peer Address:Port

tcp    LISTEN     0      5      *:8000                *:*

7157.png

Статистика сокетов (ss в терминале) — это утилита командной строки, ис­пользуемая для исследования сетевых сокетов в Linux. По сути, это современная версия netstat, и ее применение похоже на применение Netstat, что означает: вы можете передавать те же аргументы и получать сопоставимые результаты.

На этом этапе можно проверить сетевые интерфейсы на компьютере, где работает HTTP-сервер:

$ ip a

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group defau

lt qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

    inet 127.0.0.1/8 scope host lo

       valid_lft forever preferred_lft forever

    inet6 ::1/128 scope host

       valid_lft forever preferred_lft forever

2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP g

roup default qlen 1000

    link/ether 02:1e:30:9c:a3:c0 brd ff:ff:ff:ff:ff:ff

    inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic enp0s3

       valid_lft 84964sec preferred_lft 84964sec

    inet6 fe80::1e:30ff:fe9c:a3c0/64 scope link

       valid_lft forever preferred_lft forever

3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP g

roup default qlen 1000

    link/ether 08:00:27:0d:15:7d brd ff:ff:ff:ff:ff:ff

    inet 192.168.33.11/24 brd 192.168.33.255 scope global enp0s8

       valid_lft forever preferred_lft forever

    inet6 fe80::a00:27ff:fe0d:157d/64 scope link

       valid_lft forever preferred_lft forever

Обратите внимание, что машина имеет три интерфейса, но топология сети проста:

lo — это петлевой интерфейс для внутренней коммуникации;

• enp0s3 — это уровень сети управления, администраторы будут использовать данный интерфейс, чтобы подключиться к веб-серверу для выполнения своих задач;

• enp0s8 — это открытый интерфейс, наш веб-сервер должен быть скрыт от этого интерфейса.

Теперь перед загрузкой любой программы XDP мы можем проверить открытые на сервере порты с другого компьютера, который может получить доступ к его сетевому интерфейсу, в нашем случае по адресу IPv4 192.168.33.11.

Вы можете проверить открытые порты на удаленном хосте, используя nmap следующим образом:

# nmap -sS 192.168.33.11

Starting Nmap 7.70 ( https://nmap.org ) at 2019-04-06 23:57 CEST

Nmap scan report for 192.168.33.11

Host is up (0.0034s latency).

Not shown: 998 closed ports

PORT     STATE SERVICE

22/tcp   open  ssh

8000/tcp open  http-alt

Прекрасно! Порт 8000 виден, а сейчас его нужно заблокировать!

7180.png

Network Mapper (nmap) — это сетевой сканер, который наряду с операционной системой может обнаружить хост, службу, сеть и порт. Он используется в основном для аудита безопасности и сканирования сети. При сканировании хоста на наличие открытых портов nmap будет пробовать войти на каждый порт в указанном (или полном) диапазоне.

Наша программа состоит из одного исходного файла program.c, поэтому посмотрим, что в него следует включить.

Он должен задействовать структуры заголовков ethhdr IPv4 iphdr и Ethernet Frame, а также константы протокола и другие структуры. Давайте включим в него необходимые заголовки, как показано далее:

#include <linux/bpf.h>

#include <linux/if_ether.h>

#include <linux/in.h>

#include <linux/ip.h>

После включения заголовков мы можем объявить макрос SEC (его мы уже встречали в предыдущих главах), используемый для объявления атрибутов ELF:

#define SEC(NAME) __attribute__((section(NAME), used))

Теперь можем объявить основную точку входа для нашей программы myprogram и имя раздела ELF mysection. Программа применяет в качестве входного контекста структурный указатель xdp_md, эквивалентный BPF для встроенного драйвера xdp_buff. Затем определяем нужные переменные, такие как указатели данных, структуры уровня Ethernet и IP:

SEC("mysection")

int myprogram(struct xdp_md *ctx) {

  int ipsize = 0;

  void *data = (void *)(long)ctx->data;

  void *data_end = (void *)(long)ctx->data_end;

  struct ethhdr *eth = data;

  struct iphdr *ip;

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

ipsize = sizeof(*eth);

ip = data + ipsize;

ipsize += sizeof(struct iphdr);

if (data + ipsize > data_end) {

  return XDP_DROP;

}

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

  if (ip->protocol == IPPROTO_TCP) {

    return XDP_DROP;

  }

 

  return XDP_PASS;

}

Программа готова, сохраним ее под именем program.c.

Следующим шагом является компиляция файла ELF program.o из нашей программы с использованием Clang. Его можно выполнить на любом компьютере, поскольку двоичные файлы BPF ELF не зависят от платформы:

$ clang -O2 -target bpf -c program.c -o program.o

Вернувшись на компьютер, на котором размещен наш веб-сервер, мы наконец-то можем загрузить program.o с общедоступным сетевым интерфейсом enp0s8, используя утилиту ip с командой set, как описывалось ранее:

# ip link set dev enp0s8 xdp obj program.o sec mysection

Точкой входа в программу выбран раздел mysection.

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

# ip a show enp0s8

3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric/id:32

    qdisc fq_codel state UP group default qlen 1000

    link/ether 08:00:27:0d:15:7d brd ff:ff:ff:ff:ff:ff

    inet 192.168.33.11/24 brd 192.168.33.255 scope global enp0s8

       valid_lft forever preferred_lft forever

    inet6 fe80::a00:27ff:fe0d:157d/64 scope link

       valid_lft forever preferred_lft forever

Как видите, вывод для ipa дает еще кое-что: после MTU он показывает xdpgeneric/id:32, который содержит два интересных момента:

использованный драйвер — xdpgeneric;

• идентификатор программы XDP — 32.

И последнее — следует убедиться, что загруженная программа действительно выполняет то, что должна делать. Мы можем проверить это, снова запустив nmap на внешней машине, чтобы увидеть, что порт 8000 недоступен:

# nmap -sS 192.168.33.11

Starting Nmap 7.70 ( https://nmap.org ) at 2019-04-07 01:07 CEST

Nmap scan report for 192.168.33.11

Host is up (0.00039s latency).

Not shown: 998 closed ports

PORT    STATE SERVICE

22/tcp  open  ssh

Еще одним тестом для проверки работоспособности может быть попытка получить доступ к программе через браузер или выполнение любого HTTP-запроса. Любой вид теста должен быть неудачным при попытке доступа к 192.168.33.11 в качестве пункта назначения для порта 8000. Вы хорошо поработали, поздравляем с загрузкой вашей первой программы XDP!

Если вы проделали все это на своем компьютере, который необходимо привести в исходное состояние, всегда можете отсоединить программу и отключить XDP на устройстве:

# ip link set dev enp0s8 xdp off

Интересно, правда? Загрузка программ XDP кажется легкой, не так ли?

По крайней мере, используя iproute2 в качестве загрузчика, вы можете не писать его самостоятельно. В этом примере мы сосредоточились на iproute2, который уже реализует загрузчик для программ XDP. Но на самом деле программы являются BPF-программами, поэтому, даже если iproute2 иногда может быть полезен, вы всегда должны помнить, что можете загружать свои программы с помощью BCC, как показано в следующем разделе, или задействовать системный вызов bpf напрямую. Наличие собственного загрузчика позволяет вам управлять жизненным циклом программы и взаимодействием с пространством пользователя.

XDP и BCC

Как и любые другие программы BPF, программы XDP можно компилировать, загружать и запускать с помощью BCC. В следующем примере показана программа XDP, похожая на ту, которую мы использовали для iproute2, но со специальным загрузчиком пространства пользователя, созданным с помощью BCC. Загрузчик в этом случае необходим, потому что мы также хотим подсчитать количество пакетов, которые отбрасываем при фильтрации пакетов TCP.

Как и прежде, сначала создаем программу для пространства ядра с именем program.c.

В примере с iproute2 нашей программе необходимо было импортировать требуемые заголовки для определений структуры и функций, связанных с BPF и протоколами. Здесь мы делаем то же самое, а также объявляем карту типа BPF_MAP_TYPE_PERCPU_ARRAY, используя макрос BPF_TABLE. Карта будет содержать счетчик пакетов для каждого индекса протокола IP, из-за чего размер окажется 256 (спецификация IP содержит только 256 значений). Мы хотим задействовать тип BPF_MAP_TYPE_PERCPU_ARRAY, потому что он гарантирует атомарность счетчиков на уровне ЦП без блокировки:

#define KBUILD_MODNAME "program"

#include <linux/bpf.h>

#include <linux/in.h>

#include <linux/ip.h>

 

BPF_TABLE("percpu_array", uint32_t, long, packetcnt, 256);

После этого объявляем нашу основную функцию myprogram, которая принимает в качестве параметра структуру xdp_md. В первую очередь она должна содержать объявления переменных для кадров Ethernet IPv4:

int myprogram(struct xdp_md *ctx) {

  int ipsize = 0;

  void *data = (void *)(long)ctx->data;

  void *data_end = (void *)(long)ctx->data_end;

  struct ethhdr *eth = data;

  struct iphdr *ip;

  long *cnt;

  __u32 idx;

 

  ipsize = sizeof(*eth);

  ip = data + ipsize;

  ipsize += sizeof(struct iphdr);

После того как мы выполнили все объявления переменных и получили доступ к указателю данных, который теперь содержит кадр Ethernet и указатель ip с пакетом IPv4, можем проверить, не вышли ли мы за пределы доступной памяти. Если это так, отбрасываем пакет. Если пространство памяти в порядке, извлекаем протокол и ищем массив packetcnt, чтобы получить предыдущее значение счетчика пакетов для текущего протокола в переменной idx. Затем увеличиваем счетчик на единицу. Когда приращение обработано, мы можем продолжить и проверить, является ли это протоколом TCP. Если это так, просто отбрасываем пакет без вопросов, в противном случае пропускаем его:

if (data + ipsize > data_end) {

  return XDP_DROP;

}

 

idx = ip->protocol;

cnt = packetcnt.lookup(&idx);

if (cnt) {

  *cnt += 1;

}

 

if (ip->protocol == IPPROTO_TCP) {

  return XDP_DROP;

}

  return XDP_PASS;

}

Теперь напишем загрузчик loader.py. Он состоит из двух частей: фактической логики загрузки и цикла, который выводит количество пакетов.

Для логики загрузки открываем нашу программу, читая файл program.c. С помощью load_func указываем системному вызову bpf использовать функцию myprogram в качестве main, применяя тип программы BPF.XDP. Это то же самое, что и BPF_PROG_TYPE_XDP.

После загрузки мы получаем доступ к карте BPF с именем packetcnt, используя get_table.

7203.png

Обязательно измените переменную устройства с enp0s8 на интерфейс, с которым хотите работать.

#!/usr/bin/python

 

from bcc import BPF

import time

import sys

 

device = "enp0s8"

b = BPF(src_file="program.c")

fn = b.load_func("myprogram", BPF.XDP)

b.attach_xdp(device, fn, 0)

packetcnt = b.get_table("packetcnt")

Оставшаяся часть, которую нам нужно написать, — это фактически цикл для вывода количества пакетов. Без этого наша программа уже сможет отбрасывать пакеты, но мы хотим увидеть, что там происходит. У нас есть два цикла. Внешний цикл получает события клавиатуры и завершается при появлении сигнала на прерывание программы. Когда внешний цикл прерывается, вызывается функция remove_xdp и интерфейс освобождается от программы XDP.

Во внешнем цикле внутренний цикл должен возвращать значения из карты packetcnt и печатать их в формате protocol:counterpkt/s:

prev = [0] * 256

print("Printing packet counts per IP protocol-number, hit CTRL+C to stop")

while 1:

    try:

        for k in packetcnt.keys():

            val = packetcnt.sum(k).value

            i = k.value

            if val:

        delta = val - prev[i]

        prev[i] = val

        print("{}: {} pkt/s".format(i, delta))

    time.sleep(1)

except KeyboardInterrupt:

    print("Removing filter from device")

    break

 

b.remove_xdp(device, 0)

Хорошо! Теперь можем протестировать эту программу, просто запустив загрузчик с правами администратора:

# python program.py

Она будет каждую секунду выводить строку со счетчиками пакетов:

Printing packet counts per IP protocol-number, hit CTRL+C to stop

6: 10 pkt/s

17: 3 pkt/s

^CRemoving filter from device

Мы обнаружили только два типа пакетов: 6 обозначает TCP, а 17 — UDP.

Сейчас вы, вероятно, подумаете о проектах использования XDP, и это очень хорошо! Но, как всегда в программной инженерии, если вы хотите создать хорошую программу, важно сначала написать тесты или вообще написать тесты! Это важно! В следующем разделе говорится о том, как тестировать программы XDP.

Тестирование программ XDP

Самая сложная часть работы с программами XDP заключается в том, что для проверки фактического потока пакетов необходимо воспроизвести среду, в которой все компоненты усреднены. Хотя в настоящее время с применением технологий виртуализации создание рабочей среды может оказаться легкой задачей, верно также и то, что сложная настройка способна ограничивать воспроизводимость и программируемость тестовой среды. Кроме того, при анализе аспектов производительности высокопроизводительных программ XDP в виртуализированной среде стоимость виртуализации делает тест неэффективным, поскольку он стоит гораздо больше, чем фактическая обработка пакетов.

К счастью, у разработчиков ядра есть решение. Они реализовали команду, которая может использоваться для тестирования программ XDP и называется BPF_PROG_TEST_RUN.

По сути, BPF_PROG_TEST_RUN получает программу XDP для выполнения вместе с входным и выходным пакетами. Когда программа выполняется, переменная выходного пакета заполняется и возвращается код выхода XDP. Это означает, что вы можете использовать выходной пакет и код возврата в тестовых проверках! Такую технику можно применять и для программ skb.

Чтобы обеспечить полноту и простоту этого примера, возьмем язык Python и его фреймворк для модульного тестирования.

XDP-тестирование с использованием фреймворка Python для тестирования модулей

Написание XDP-тестов с BPF_PROG_TEST_RUN и их интеграция с тестом инфраструктуры модульного тестирования Python — хорошая идея по нескольким причинам.

Вы можете загружать и выполнять программы BPF с помощью библиотеки Python BCC.

• В Python одна из лучших доступных библиотек для создания и анализа пакетов — scapy.

• Python интегрируется со структурами C с использованием контрольных типов.

Как уже говорилось, нам нужно импортировать все необходимые библиотеки — это первое, что мы сделаем в файле с именем test_xdp.py:

from bcc import BPF, libbcc

from scapy.all import Ether, IP, raw, TCP, UDP

 

import ctypes

import unittest

 

class XDPExampleTestCase(unittest.TestCase):

    SKB_OUT_SIZE = 1514  # mtu 1500 + 14 ethernet size

    bpf_function = None

После того как все нужные библиотеки импортированы, можем продолжить и создать класс тестового примера с именем XDPExampleTestCase. Этот тестовый класс будет содержать все наши тесты и метод-член (_xdp_test_run), который мы будем использовать для выполнения утверждений и вызова bpf_prog_test_run.

В следующем коде показано, как выглядит _xdp_test_run:

def _xdp_test_run(self, given_packet, expected_packet, expected_return):

    size = len(given_packet)

 

    given_packet = ctypes.create_string_buffer(raw(given_packet), size)

    packet_output = ctypes.create_string_buffer(self.SKB_OUT_SIZE)

 

    packet_output_size = ctypes.c_uint32()

    test_retval = ctypes.c_uint32()

    duration = ctypes.c_uint32()

    repeat = 1

    ret = libbcc.lib.bpf_prog_test_run(self.bpf_function.fd,

                                       repeat,

                                       ctypes.byref(given_packet),

                                       size,

                                       ctypes.byref(packet_output),

                                       ctypes.byref(packet_output_size),

                                       ctypes.byref(test_retval),

                                       ctypes.byref(duration))

    self.assertEqual(ret, 0)

    self.assertEqual(test_retval.value, expected_return)

 

    if expected_packet:

        self.assertEqual(

            packet_output[:packet_output_size.value], raw(expected_packet))

Требуются три аргумента:

given_packet — это необработанный полученный интерфейсом пакет, с которым мы тестируем нашу программу XDP;

• expected_packet — это пакет, который мы ожидаем получить обратно после обработки программой XDP. Когда программа XDP возвращает XDP_DROP или XDP_ABORT, ожидается, что это будет None, во всех остальных случаях пакет остается таким же, как прежде, или может быть изменен;

• expected_return — это ожидаемое возвращение программы XDP после обработки данного пакета.

Если не учитывать аргументы, сам метод довольно прост. Он выполняет преобразование в типы C с помощью библиотеки ctypes, а затем вызывает libbcc, эквивалентный BPF_PROG_TEST_RUN, — libbcc.lib.bpf_prog_test_run, используя в качестве аргументов теста пакеты и их метаданные. Затем выполняет все утверждения на основе результатов тестового вызова вместе с заданными значениями.

Теперь, когда функция создана, можно написать контрольные примеры, создав различные пакеты для проверки их поведения при прохождении через программу XDP. Но перед этим требуется написать метод setUp для теста.

Эта часть работы очень важна, потому что программа установки выполняет фактическую загрузку нашей программы BPF с именем myprogram, открывая и компилируя исходный файл program.c (в нем будет содержаться код XDP):

def setUp(self):

    bpf_prog = BPF(src_file=b"program.c")

    self.bpf_function = bpf_prog.load_func(b"myprogram", BPF.XDP)

Следующим после завершения настройки шагом является написание кода для того, что мы, собственно, хотим в данном случае. Не будучи слишком изобретательными, проверим, что отбрасываем все TCP-пакеты. Поэтому мы создаем пакет в given_packet, который является просто TCP-пакетом IPv4. Затем, используя метод подтверждения _xdp_test_run, проверяем, что с учетом нашего пакета мы получим обратно XDP_DROP без обратного пакета:

def test_drop_tcp(self):

    given_packet = Ether() / IP() / TCP()

    self._xdp_test_run(given_packet, None, BPF.XDP_DROP)

Поскольку этого недостаточно, проверяем, разрешены ли все пакеты UDP. Затем создаем два UDP-пакета — для given_packet и expected_packet, которые, по сути, одинаковы. Таким образом, мы также проверяем, что UDP-пакеты не изменяются, хотя и разрешены с помощью XDP_PASS:

def test_pass_udp(self):

    given_packet = Ether() / IP() / UDP()

    expected_packet = Ether() / IP() / UDP()

    self._xdp_test_run(given_packet, expected_packet, BPF.XDP_PASS)

Чтобы немного усложнить задачу, мы решили, что эта система затем передаст разрешенные пакеты TCP при условии, что они перейдут на порт 9090. Когда это произойдет, они также будут перезаписаны, чтобы изменился их MAC-адрес назначения для перенаправления на определенный сетевой интерфейс с адресом 08:00:27:dd:38:2a.

Например, сделаем следующим образом. Для given_packet в качестве порта назначения используется 9090, и нам требуется expected_packet с новым назначением и портом 9090:

def test_transform_dst(self):

    given_packet = Ether() / IP() / TCP(dport=9090)

    expected_packet = Ether(dst='08:00:27:dd:38:2a') / \

        IP() / TCP(dport=9090)

    self._xdp_test_run(given_packet, expected_packet, BPF.XDP_TX)

Имея множество тестов, напишем точку входа для тестовой программы, которая просто вызовет unittest.main(), а затем загрузит и выполнит тесты:

if __name__ == '__main__':

    unittest.main()

Итак, мы написали тесты для нашей программы XDP! Теперь, когда у нас есть тест — конкретный пример того, что мы хотим получить, — можем написать программу XDP, которая реализует его, создав файл с именем program.c.

Наша программа очень простая, она содержит только функцию XDP myprogram с проверенной логикой. Как всегда, первое, что нужно сделать, — включить необходимые заголовки, которые говорят сами за себя. У нас есть программа BPF, которая обрабатывает TCP/IP, передаваемый через Ethernet:

#define KBUILD_MODNAME "kmyprogram"

 

#include <linux/bpf.h>

#include <linux/if_ether.h>

#include <linux/tcp.h>

#include <linux/in.h>

#include <linux/ip.h>

Вновь, как и в ходе работы с другими программами в этой главе, нужно проверить смещения и заполнить переменные для трех уровней пакета: ethhdr, iphdr и tcphdr для Ethernet, IPv4 и TCP соответственно:

int myprogram(struct xdp_md *ctx) {

  int ipsize = 0;

  void *data = (void *)(long)ctx->data;

  void *data_end = (void *)(long)ctx->data_end;

  struct ethhdr *eth = data;

  struct iphdr *ip;

  struct tcphdr *th;

 

  ipsize = sizeof(*eth);

  ip = data + ipsize;

  ipsize += sizeof(struct iphdr);

  if (data + ipsize > data_end) {

    return XDP_DROP;

  }

Теперь, получив значения, можем реализовать нашу логику.

В первую очередь проверяем, является ли протокол TCPip->protocol==IPPROTO_TCP. Если это так, мы всегда применяем XDP_DROP, в противном случае используем XDP_PASS для всего остального.

При проверке протокола TCP выполняем еще одну проверку, чтобы выяснить, является ли порт назначения 9090 — th->dest==htons(9090). Если это так, изменяем MAC-адрес назначения на уровне Ethernet и возвращаем XDP_TX, чтобы отослать пакет через тот же NIC:

if (ip->protocol == IPPROTO_TCP) {

  th = (struct tcphdr *)(ip + 1);

  if ((void *)(th + 1) > data_end) {

    return XDP_DROP;

  }

 

  if (th->dest == htons(9090)) {

    eth->h_dest[0] = 0x08;

    eth->h_dest[1] = 0x00;

    eth->h_dest[2] = 0x27;

    eth->h_dest[3] = 0xdd;

    eth->h_dest[4] = 0x38;

    eth->h_dest[5] = 0x2a;

    return XDP_TX;

  }

 

  return XDP_DROP;

}

 

  return XDP_PASS;

}

Ну что ж, теперь можем запустить наши тесты:

sudo python test_xdp.py

Результат сообщит, что эти три теста пройдены:

...

--------------------------------

Ran 3 tests in 4.676s

 

OK

На данном этапе разобраться в происходящем несложно. Мы можем изменить последний XDP_PASS на XDP_DROP в program.c и посмотреть, что произойдет:

.F.

======================================================================

FAIL: test_pass_udp (__main__.XDPExampleTestCase)

----------------------------------------------------------------------

Traceback (most recent call last):

  File "test_xdp.py", line 48, in test_pass_udp

    self._xdp_test_run(given_packet, expected_packet, BPF.XDP_PASS)

  File "test_xdp.py", line 31, in _xdp_test_run

    self.assertEqual(test_retval.value, expected_return)

AssertionError: 1 != 2

 

----------------------------------------------------------------------

Ran 3 tests in 4.667s

 

FAILED (failures=1)

Тест не пройден — код состояния не совпадает и тестовая структура сообщила об ошибке. Это именно то, что нам требовалось! Получена эффективная среда тестирования, позволяющая уверенно писать программы XDP. Теперь у нас есть возможность делать что-то на конкретных этапах и изменять данное поведение в соответствии с тем, что нужно получить. Затем мы пишем код, чтобы показать это поведение в форме программы XDP.

7234.png

MAC-адрес слишком короток для адреса Media Access Controll. Это уникальный идентификатор, состоящий из двух групп шестнадцатеричных цифр, которые есть у каждого сетевого интерфейса и которые применяются на канальном уровне (уровень 2 в модели OSI) для соединения устройств по таким технологиям, как Ethernet, Bluetooth и Wi-Fi.

Варианты использования XDP

Знакомясь с XDP, безусловно, полезно знать варианты его реализации различными организациями по всему миру. Это может помочь вам понять, почему в некоторых случаях XDP лучше, чем другие методы, такие как фильтрация сокетов или управление трафиком.

Начнем с наиболее часто употребляемого — мониторинга.

Мониторинг

Сейчас большинство систем мониторинга сети реализуются либо написанием модулей ядра, либо путем доступа к файлам процедур из пользовательского пространства. Написание, распространение и компиляция модулей ядра — задача не для всех, это опасная операция. Их нелегко поддерживать и отлаживать. Однако альтернатива может быть еще хуже. Чтобы получить такую информацию, как, например, количество пакетов, принятых картой в секунду, вам нужно открыть и уметь прочитать файл, в данном случае /sys/class/net/eth0/statistics/rx_packets. Это может показаться хорошей идеей, но для получения простой информации требуется много вычислений, поскольку открытый системный вызов в некоторых случаях обходится недешево в системном смысле.

Так что нам нужно решение, которое позволит реализовать функции, аналогичные функциям модуля ядра, без потери производительности. XDP идеально подходит для этого, потому что мы можем с помощью программы XDP отправлять данные, которые хотим извлечь в карту. Затем ее использует загрузчик, способный сохранять показатели в бэкенд-хранилище и применять к нему алгоритмы или отображать результаты в виде графика.

Миграция DDoS

Возможность видеть пакеты на уровне NIC гарантирует, что любой из них будет перехвачен на первом этапе, когда система еще не потратила значительную вычислительную мощность, чтобы понять, будут ли пакеты полезны для нее. В типичном сценарии карта bpf может инструктировать программу XDP для пакетов XDP_DROP из определенного источника. Список пакетов может быть сгенерирован в пространстве пользователя после анализа пакетов, полученных через другую карту. Как только пакет, поступающий в программу XDP, совпадает с элементом списка, происходит его обработка. Пакет отбрасывается, так что ядру не придется тратить цикл процессора на его обработку. Это приводит к тому, что цель злоумышленника становится труднодостижимой, поскольку в таком случае он не может тратить впустую дорогостоящие вычислительные ресурсы.

Балансировка нагрузки

Интересный пример использования программ XDP — балансировка нагрузки, однако XDP может повторно передавать пакеты только на тот же сетевой адаптер, куда они поступили. Это означает, что XDP — не лучший вариант для реализации классического балансировщика нагрузки, который расположен перед всеми вашими серверами и перенаправляет трафик на них. Однако это не означает, что XDP не подходит как вариант. Если мы перенесем балансировку нагрузки с внешнего сервера на те же машины, которые обслуживают приложение, вы сразу увидите, как их сетевые карты можно использовать для балансировки.

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

Брандмауэры

Когда люди представляют брандмауэр в Linux, они обычно думают о iptables или netfilter. С XDP вы можете получить ту же функциональность полностью программируемым способом непосредственно в сетевой карте или ее драйвере. Обычно брандмауэры — это дорогие машины, расположенные поверх сетевого стека или между узлами для контроля связи. Однако при использовании XDP сразу становится ясно: поскольку программы XDP очень дешевы и быстры, мы могли бы внедрить логику брандмауэра непосредственно в сетевые адаптеры узлов вместо набора выделенных машин. Обычный вариант — иметь загрузчик XDP, который управляет картой с набором правил, измененных с помощью API удаленного вызова процедур. Затем набор правил, содержащихся в карте, динамически передается программам XDP, загруженным на каждый конкретный компьютер, для управления тем, что он может получать, от кого и в какой ситуации.

Эта альтернатива не просто делает брандмауэр менее дорогим — каждый узел может развертывать собственный уровень межсетевого экрана, не полагаясь на программное обеспечение пользовательского пространства или ядро. Когда развертывание выполняется с помощью выгруженного XDP в рабочем режиме, мы получаем максимальное преимущество, потому что центральный процессор даже не обрабатывает пакеты.

Резюме

Вы получили действительно нужные знания! Мы обещаем, что XDP поможет вам думать о сетевых потоках совершенно по-другому. Необходимость полагаться на такие инструменты, как iptables или другие инструменты пользовательского пространства, работая с сетевыми пакетами, часто разочаровывает и замедляет процесс. XDP интересен тем, что он быстрее, так как способен обрабатывать пакеты напрямую, а вы можете написать собственную логику для работы с сетевыми пакетами. Поскольку весь созданный вами код может работать с картами и взаимодействовать с другими программами BPF, у вас есть множество вариантов его применения, для того чтобы изобретать и улучшать собственные архитектуры!

Несмотря на то что в следующей главе речь идет не о сети, мы рассмотрим многие концепции, описанные в главах 6 и 7. К тому же BPF используется для фильтрации некоторых условий на основе заданного ввода, а также того, что может сделать программа. Не забывайте, что F в BPF означает фильтр!

Назад: 6. Сетевое взаимодействие в Linux и BPF
Дальше: 8. Безопасность ядра Linux, его возможности и Seccomp