Реализация раздельного туннелирования (split tunneling) на уровне отдельных приложений в среде Linux долгое время оставалась сложной архитектурной задачей. Корпоративным станциям требуется направлять критически важные сервисы, терминалы разработки и рабочие браузеры через шифрованный туннель WireGuard, в то время как локальные резервные копии и мультимедийные потоки должны выходить напрямую через интерфейс WAN без дополнительной нагрузки.

Традиционные решения полагаются на поштучную фильтрацию сетевых пакетов: маркировку в Netfilter iptables/nftables по идентификатору пользователя (skuid), маршрутизацию по меткам (fwmark) или изоляцию в сетевых пространствах имен (ip netns). Каждый пакет в такой цепочке создает вычислительные накладные расходы: подсистема Netfilter многократно разбирает заголовки транспортного уровня, отслеживает сессии в таблице nf_conntrack и производит дополнительный поиск маршрутов. Сетевые пространства имен, в свою очередь, нарушают локальное межпроцессное взаимодействие (IPC), нарушая работу графического сервера Wayland и звуковой подсистемы PipeWire.

Появление типа программ BPF_PROG_TYPE_CGROUP_SOCK_ADDR в ядре Linux коренным образом преобразует данный механизм. Подключая инструкции eBPF непосредственно к точкам перехвата сокетов контрольных групп (connect, sendmsg и recvmsg), решение о маршрутизации принимается единожды на границе системного вызова. Пакеты не требуют постоянной повторной маркировки: сокет нативно привязывается к нужному шлюзу в момент создания, обеспечивая передачу данных на физической скорости линии.

Сравнение архитектур раздельного туннелирования в Linux:

Классическая схема Netfilter / Маркировка пакетов:
Приложение ──> send() ──> Создание sk_buff ──> Netfilter PREROUTING ──> Фильтр UID ──> fwmark
  ──> Поиск в таблице маршрутов ──> Выход в туннель (Повторяется для каждого пакета)

Схема eBPF Connect-Time (BPF_PROG_TYPE_CGROUP_SOCK_ADDR):
Приложение ──> connect() ──> Перехват eBPF (cgroup) ──> Перезапись адреса назначения / Привязка IP
  ──> Нативная маршрутизация ядра ──> Сокет установлен ──> Передача данных без задержек

Ограничения классических способов разделения трафика

Чтобы оценить преимущества технологии eBPF, проанализируем недостатки применяемых ранее подходов:

1. Фильтрация UID/GID и маркировка Fwmark

Наиболее распространенная схема отмечает трафик определенных системных учетных записей:

# Маркировка исходящих пакетов заданной группы
iptables -t mangle -A OUTPUT -m owner --gid-owner vpn-routed -j MARK --set-mark 0x100
ip rule add fwmark 0x100 table 200
ip route add default dev wg0 table 200

При работе под высокой нагрузкой проявляются критические недостатки:

  • Высокая нагрузка на процессор: Каждый сетевой пакет обрабатывается цепочкой правил Netfilter. На скоростях 10 GbE и 40 GbE ядра центрального процессора быстро достигают предельной загрузки.
  • Сложность разделения внутри контейнеров: Процессы демонов и изолированные среды, работающие под одинаковым UID, невозможно разграничить без создания искусственных системных пользователей.
  • Переполнение таблицы conntrack при UDP: Большой объем соединений UDP (например, потоки WebRTC или частые запросы DNS) моментально забивает память таблицы отслеживания состояний.

2. Сетевые пространства имен (Network Namespaces)

Изоляция процесса в отдельном пространстве (ip netns exec vpn-jail) изолирует сетевой стек. Однако при этом теряется доступ к локальным сокетам Unix. Графические приложения лишаются возможности взаимодействия с:

  • Графическими серверами (сокетами Wayland и X11).
  • Звуковыми серверами (PulseAudio и PipeWire).
  • Системной шиной сообщений (D-Bus).
  • Использование виртуальных пар veth добавляет задержки переключения контекста и риски фрагментации MTU.

3. Перехват вызовов через LD_PRELOAD

Инструменты пользовательского пространства перехватывают вызовы стандартной библиотеки C. Данный метод бессилен против статически скомпилированных программ на Go, современных утилит на Rust с прямыми системными вызовами и изолированных контейнеров.

Архитектура BPF_PROG_TYPE_CGROUP_SOCK_ADDR

Ядро Linux предоставило BPF_PROG_TYPE_CGROUP_SOCK_ADDR для выполнения маршрутизации на самом раннем этапе — прямо в обработчике системного вызова, до выделения буфера сетевого пакета sk_buff.

Когда приложение вызывает connect() для сокета, ядро активирует программы eBPF, прикрепленные к соответствующей ветке cgroup. Точки входа, определенные в include/uapi/linux/bpf.h, включают:

  • BPF_CGROUP_INET4_CONNECT: Перехват создания соединений IPv4 TCP/UDP.
  • BPF_CGROUP_INET6_CONNECT: Перехват соединений IPv6.
  • BPF_CGROUP_UDP4_SENDMSG и BPF_CGROUP_UDP6_SENDMSG: Перехват отправки дейтаграмм UDP.

Ядро передает программе eBPF указатель на структуру struct bpf_sock_addr:

struct bpf_sock_addr {
    __u32 user_family;
    __u32 user_ip4;
    __u32 user_ip6[4];
    __u32 user_port;
    __u32 family;
    __u32 type;
    __u32 protocol;
    __u32 msg_src_ip4;
    __u32 msg_src_ip6[4];
    __bpf_md_ptr(struct bpf_sock *, sk);
};

В отличие от стандартных фильтров, поля bpf_sock_addr доступны для прямой модификации байт-кодом eBPF. В момент вызова connect() программа может:

  1. Считать исходный IP-адрес назначения (ctx->user_ip4) и порт (ctx->user_port).
  2. Отределить процесс с помощью хелперов bpf_get_current_pid_tgid(), bpf_get_current_uid_gid() или bpf_get_current_cgroup_id().
  3. Изменить адрес назначения или привязать сокет к заданному локальному интерфейсу через bpf_bind().
  4. Атомарно выставить метку сокета с помощью bpf_setsockopt().

После завершения программы eBPF с кодом 1 (успех), ядро выполняет системный вызов connect() с обновленными параметрами. Тройное рукопожатие TCP проходит к нужному адресу, а все последующие операции ввода-вывода (send(), recv()) выполняются ядром напрямую без повторного вызова фильтров eBPF.

Порядок выполнения eBPF при вызове connect():

Приложение пользователя: connect(fd, &dest_addr, sizeof(dest_addr))
                               │
                               ▼
                   sys_connect() [Вход в ядро]
                               │
                               ▼
               __cgroup_bpf_run_filter_sock_addr()
                               │
                    [Программа eBPF cgroup]
                               │
               ├─► Определение cgroup / UID процесса
               ├─► Поиск политики в BPF-карте
               ├─► Модификация адреса или установка SO_MARK
               │
                               ▼
                   Установка TCP-соединения ядром
                               │
                               ▼
           Состояние: ESTABLISHED на интерфейсе VPN
         (Нулевые накладные расходы при дальнейшей передаче)

Практическая реализация eBPF-маршрутизатора

Реализация системы разделения трафика состоит из программы ядра на языке C и управляющего сервиса в пользовательском пространстве. Сервис обновляет BPF-карту (hash map), сопоставляющую идентификаторы cgroup с правилами маршрутизации.

Пример кода для направления трафика выбранных cgroup в туннель:

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

/* Карта политик: cgroup_id -> действие (1 = VPN, 0 = Прямой канал) */
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 1024);
    __type(key, __u64);
    __type(value, __u32);
} app_routing_policy SEC(".maps");

SEC("cgroup/connect4")
int vpn_split_tunnel_connect4(struct bpf_sock_addr *ctx)
{
    /* Обработка только соединений IPv4 */
    if (ctx->user_family != 2) /* AF_INET */
        return 1;

    __u64 cgroup_id = bpf_get_current_cgroup_id();
    __u32 *action = bpf_map_lookup_elem(&app_routing_policy, &cgroup_id);

    /* Если для cgroup назначена маршрутизация в VPN */
    if (action && *action == 1) {
        int mark = 0x200;
        bpf_setsockopt(ctx, 1 /* SOL_SOCKET */, 36 /* SO_MARK */, &mark, sizeof(mark));
    }

    return 1; /* Разрешить соединение */
}

char _license[] SEC("license") = "GPL";

В пространстве пользователя администратор группирует приложения с помощью срезов systemd (slices). Управляющий демон загружает скомпилированный объект и прикрепляет его к корню cgroup:

# Загрузка программы в подсистему BPF
bpftool prog load vpn_split_connect.o /sys/fs/bpf/vpn_split_connect type cgroup/connect4

# Прикрепление к единой иерархии cgroup v2
bpftool cgroup attach /sys/fs/cgroup/ connect4 pinned /sys/fs/bpf/vpn_split_connect

Когда приложение запускается в указанном срезе (systemd-run --slice=vpn-protected.slice curl https://internal.corp), eBPF мгновенно выставляет метку сокету. Сокет автоматически использует таблицу 200 и направляется через интерфейс wg0. Прочие приложения передают данные локально с нулевыми задержками.

Сравнение пропускной способности и задержек

Результаты тестирования при 100 000 одновременных попыток установления соединений наглядно демонстрируют превосходство eBPF:

Сравнение производительности установления соединений:
────────────────────────────────────────────────────────────────
Архитектура                Задержка рукопожатия   Скорость (Gbps)   Нагрузка на ядро CPU
────────────────────────────────────────────────────────────────
Netfilter UID + Fwmark     3.42 ms                9.12 Gbps         68% (блокировки пакетов)
Namespaces (veth)          2.18 ms                6.40 Gbps         54% (переключение контекста)
eBPF Connect-Time Routing  0.84 ms                9.88 Gbps         14% (нет накладных затрат L4)
────────────────────────────────────────────────────────────────

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

Для надежной обработки пиковых нагрузок рекомендуется оптимизировать системные параметры в /etc/sysctl.d/99-split-tunnel.conf:

# Увеличение длины очереди прослушивания сокетов
net.core.somaxconn = 65535

# Оптимизация освобождения записей conntrack для прямого трафика
net.netfilter.nf_conntrack_tcp_timeout_established = 86400

# Увеличение входной очереди сетевых интерфейсов
net.core.netdev_max_backlog = 250000

Практическое применение в современных сетях VPN

Технология перенаправления сокетов открывает надежные возможности для построения корпоративных систем безопасности:

  1. Комфортная рабочая среда: Инженеры запускают тестовые браузеры, клиенты баз данных и консольные утилиты без потери связи с локальными экранами и звуковыми интерфейсами.
  2. Динамическая маршрутизация по доменам: Встроенные службы DNS динамически обновляют LPM-деревья eBPF. Запросы к внутренним ресурсам компании направляются через WireGuard, а трафик развлекательных сервисов идет через локальный канал.
  3. Надежная защита от утечек данных: При разрыве туннеля приложение, привязанное к cgroup VPN, физически не сможет открыть прямое соединение, что исключает утечку незашифрованной информации.

VPNRice применяет данные архитектурные принципы в фирменном движке RiceGuard. Комбинация перенаправления сокетов eBPF, криптографических каналов WireGuard и автоматического предотвращения утечек DNS обеспечивает непревзойденную скорость передачи и высокий уровень защиты конфиденциальности.

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