В предыдущей статье я подробно разбирал, как автоматически разворачивать собственные серверы Hysteria2 через Ansible, получать сертификаты, создавать пользователей и превращать несколько VPS в нормальную воспроизводимую инфраструктуру вместо коллекции команд из истории shell. В результате у нас появились серверы Hysteria2 в нескольких географических точках и готовые URI подключения вида hysteria2://user:password@host:443. Но довольно быстро возник следующий совершенно логичный вопрос: если эти серверы уже существуют, почему через них должен ходить только ноутбук или телефон, а домашняя и серверная инфраструктура продолжает самостоятельно решать проблемы с доступом к внешним сервисам? Предыдущая схема хорошо решает транспортную часть задачи, теперь пришло время поставить над ней нормальный маршрутизатор.

В моём случае задача начиналась довольно безобидно. Хотелось, чтобы телевизор открывал YouTube без установки дополнительного программного обеспечения, некоторые устройства могли выборочно ходить к нужным сервисам через Hysteria2, а Kubernetes спокойно отправлял уведомления в Telegram независимо от того, насколько удачно сегодня сложилась международная маршрутизация. При этом совершенно не хотелось превращать Hysteria2 в глобальный шлюз по умолчанию, через который внезапно поедут Plex, Home Assistant, российские сервисы, локальные адреса, DNS домашней сети и вообще всё, что не успело спрятаться. Так появился Split Gateway - небольшой шлюз на Linux с sing-box, TPROXY, policy routing и несколькими Hysteria2-серверами за одним логическим выходом.

Хороший split gateway не должен быть заметен приложениям. Если каждое приложение знает адрес вашего VPS, пароль Hysteria2 и страну, в которой сегодня лучше работает маршрут, у вас не шлюз, а распределённая конфигурационная авария.

В этой статье соберём всю схему целиком. Я покажу, как устроен прозрачный перехват TCP и UDP через TPROXY, зачем нам отдельная таблица маршрутизации Linux, как sing-box определяет Telegram и другие сервисы, почему DNS тоже необходимо разделять, как выбирать самый быстрый Hysteria2-сервер автоматически и как использовать один и тот же транспорт не только для телевизора, но и для Alertmanager, скриптов, приложений в Docker и сервисов Kubernetes. В результате получится инфраструктурный компонент, который можно развернуть через Ansible непосредственно на Linux, запустить в Kubernetes или адаптировать для Docker Compose.

⚠️ Дисклеймер

Материал посвящён маршрутизации, отказоустойчивости, Linux networking, Kubernetes и автоматизации сетевой инфраструктуры. Используйте описанные механизмы в соответствии с законодательством вашей страны, правилами используемых сервисов и требованиями вашей организации.

Что мы вообще строим

Начнём с архитектуры, потому что без неё комбинация iptables, fwmark, TPROXY, DNS и Hysteria2 довольно быстро превращается в сетевое шаманство. У нас есть несколько VPS с Hysteria2, например в Нидерландах, Казахстане и ОАЭ. На домашнем или серверном узле работает sing-box, внутри которого настроены три Hysteria2 outbound, то есть три возможных внешних маршрута. Над ними находится urltest, который периодически проверяет доступность серверов и выбирает наиболее быстрый. Hysteria2 при этом не запускается отдельным клиентским процессом - поддержка протокола встроена непосредственно в sing-box. В официальной документации sing-box Hysteria2 является штатным outbound, а urltest - отдельным типом outbound для выбора рабочего маршрута.

Схематично получается примерно так:

                    ┌─────────────────────────────┐
                    │         Internet            │
                    └──────────────┬──────────────┘
                                   │
             ┌─────────────────────┼─────────────────────┐
             │                     │                     │
             ▼                     ▼                     ▼
        Hysteria2 NL          Hysteria2 KZ          Hysteria2 AE
             │                     │                     │
             └─────────────────────┼─────────────────────┘
                                   │
                             urltest "proxy"
                                   │
                         выбирает лучший VPS
                                   │
                    ┌──────────────▼──────────────┐
                    │          sing-box           │
                    │                             │
                    │ Telegram -> proxy           │
                    │ YouTube  -> proxy           │
                    │ Meta     -> proxy           │
                    │ .ru      -> direct          │
                    │ RFC1918  -> direct          │
                    │ остальное -> direct         │
                    └──────────────┬──────────────┘
                                   │
                  ┌────────────────┴────────────────┐
                  │                                 │
           TPROXY :7893                      HTTP proxy :7890
                  │                                 │
          телевизор / LAN                  приложения / K8s

Здесь есть две принципиально разные двери. Первая предназначена для устройств, которые вообще ничего не знают о прокси. Телевизор получает обычный адрес по DHCP, считает домашний маршрутизатор своим шлюзом и спокойно запускает приложение YouTube. Маршрутизатор по политике маршрутизации отправляет трафик этого устройства на Split Gateway, Linux перехватывает TCP и UDP через TPROXY, а дальше решение принимает sing-box.

Вторая дверь нужна серверным приложениям. Alertmanager, Python-сервис, curl, контейнер или приложение в Kubernetes прекрасно умеют работать через HTTP-прокси, поэтому заставлять их проходить через прозрачный TPROXY совершенно необязательно. Для них мы добавим обычный HTTP-вход sing-box, после чего приложение сможет использовать HTTP_PROXY/HTTPS_PROXY или параметр proxy_url. Это особенно удобно для Alertmanager: его HTTP-клиент официально поддерживает proxy_url, а Telegram является штатной интеграцией Alertmanager.

Это важное разделение. Исходный Split Gateway перехватывает именно транзитные пакеты выбранных IP-адресов через PREROUTING, поэтому Kubernetes-под сам по себе не начнёт использовать его только потому, что рядом появился красивый DaemonSet. Для серверных приложений нужен либо сетевой маршрут через gateway, либо явный прокси-вход. Я предпочитаю второй вариант: он проще диагностируется, не затрагивает весь исходящий трафик пода и позволяет совершенно точно понимать, какой компонент использует внешний маршрут.

Почему вообще sing-box

Можно было собрать такую конструкцию из нескольких компонентов. Отдельный клиент Hysteria2, локальный SOCKS-прокси, redsocks, правила iptables, отдельный DNS-прокси, health-check скрипт и ещё какой-нибудь маленький демон, который будет переключать конфигурацию между VPS. Через пару вечеров это даже заработает. Через полгода каталог /opt/network-magic превратится в маленькое кладбище технологий, где рядом будут лежать три shell-скрипта, конфигурация dnsmasq, забытый контейнер, cron-задача и файл proxy-final-v2-really-final.conf.

Мне хотелось обратного. Один процесс должен принимать трафик, анализировать его, резолвить DNS, выбирать маршрут и устанавливать Hysteria2-соединение. Именно поэтому в центре конструкции находится sing-box.

На Linux sing-box поддерживает TPROXY inbound, причём такой вход может принимать и TCP, и UDP. Это критически важно для современных приложений, потому что попытка сделать прозрачный шлюз только для TCP очень быстро встречается с QUIC и другими протоколами поверх UDP. Официальная документация прямо указывает, что TProxy поддерживается на Linux, а при пустом поле network принимает оба транспортных протокола.

В исходной конфигурации вход выглядит так:

{
  "type": "tproxy",
  "tag": "tproxy-in",
  "listen": "0.0.0.0",
  "listen_port": 7893,
  "tcp_fast_open": true,
  "udp_fragment": true,
  "udp_timeout": "5m"
}

Сам по себе этот блок ничего не перехватывает. Он только говорит sing-box: если ядро Linux доставит сюда прозрачное соединение, нужно принять его с сохранением исходного назначения. Вся интересная часть происходит перед этим в iptables и policy routing.

Как пакет телевизора оказывается внутри sing-box

Представим Android TV с адресом 192.168.50.84. Телевизор считает обычный Keenetic 192.168.50.1 своим шлюзом, а на маршрутизаторе существует политика, которая говорит: пакеты от 192.168.50.84 отправлять через Linux-узел 192.168.50.254.

На этом узле создаётся цепочка:

iptables -t mangle -N SPLITGW

А затем только трафик выбранных источников отправляется в неё:

iptables -t mangle \
    -A PREROUTING \
    -i eth0 \
    -s 192.168.50.84/32 \
    -j SPLITGW

Дальше начинается фильтрация. Loopback нам не нужен:

iptables -t mangle -A SPLITGW \
    -i lo \
    -j RETURN

Уже помеченные пакеты тоже нельзя отправлять обратно в тот же механизм:

iptables -t mangle -A SPLITGW \
    -m mark --mark 0x1 \
    -j RETURN

Это защита от повторного попадания пакета в собственную цепочку. В сетевой инфраструктуре петли вообще обладают удивительной способностью появляться ровно в пятницу вечером, поэтому такие проверки лучше добавлять до того, как они понадобятся.

Отдельно исключаем сам порт TPROXY:

iptables -t mangle -A SPLITGW \
    -p tcp --dport 7893 \
    -j RETURN

iptables -t mangle -A SPLITGW \
    -p udp --dport 7893 \
    -j RETURN

Затем исключаются loopback, multicast и зарезервированные диапазоны:

iptables -t mangle -A SPLITGW -d 127.0.0.0/8 -j RETURN
iptables -t mangle -A SPLITGW -d 224.0.0.0/4 -j RETURN
iptables -t mangle -A SPLITGW -d 240.0.0.0/4 -j RETURN

Но самое интересное начинается с локальной сети.

Почему локальную сеть нельзя отправлять в TPROXY

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

iptables -t mangle -A SPLITGW \
    -p tcp \
    -j TPROXY \
    --on-port 7893 \
    --tproxy-mark 0x1/0x1

Кажется логичным: поймали TCP, передали в sing-box, дальше он разберётся. Проблема заключается в том, что телевизор общается не только с интернетом. В домашней сети могут находиться Plex, Home Assistant, NAS, Chromecast, DNS-сервер, маршрутизатор и ещё десяток сервисов, о существовании которых вспоминаешь исключительно после того, как они перестали работать.

Поэтому RFC1918-сети обходят TPROXY:

iptables -t mangle -A SPLITGW -d 10.0.0.0/8     -j RETURN
iptables -t mangle -A SPLITGW -d 172.16.0.0/12  -j RETURN
iptables -t mangle -A SPLITGW -d 192.168.0.0/16 -j RETURN
iptables -t mangle -A SPLITGW -d 169.254.0.0/16 -j RETURN

Кроме них существует ещё одна неприятная категория адресов - собственный внешний IP домашней сети. Например, Home Assistant может быть опубликован наружу как home.example.com, DNS возвращает внешний адрес роутера, а клиент внутри LAN обращается к нему через NAT loopback. Если такой адрес сначала завернуть в sing-box, а затем отправить наружу, можно получить весьма творческие варианты hairpin-маршрутизации.

Поэтому в переменных есть отдельный список:

splitgw_direct_destinations:
  - "203.0.113.10/32"

А шаблон превращает его в обычный RETURN:

iptables -t mangle \
    -A SPLITGW \
    -d 203.0.113.10/32 \
    -j RETURN

Однажды я довольно долго смотрел на идеально работающий YouTube и одновременно неработающий Plex. Сетевые счётчики росли, Hysteria2 был счастлив, sing-box ничего подозрительного не сообщал, а телевизор утверждал, что медиасервер умер. В итоге выяснилось, что Plex исправно путешествовал к собственному дому через внешний адрес, как человек, который вышел из квартиры, обошёл квартал и попытался попасть на кухню через пожарную лестницу. После этого splitgw_direct_destinations перестал казаться косметической настройкой.

DHCP трогать нельзя

Отдельно необходимо исключить DHCP. Устройство ещё не получило полноценную сетевую конфигурацию, поэтому пытаться отправить его DHCP Discover через прозрачный прокси - довольно оригинальный способ лишить себя клиента ещё до начала работы.

В исходном шаблоне это сделано так:

iptables -t mangle -A SPLITGW \
    -p udp --sport 68 --dport 67 \
    -j RETURN

iptables -t mangle -A SPLITGW \
    -p udp -d 192.168.50.1 --dport 67 \
    -j RETURN

iptables -t mangle -A SPLITGW \
    -p udp -d 255.255.255.255 --dport 67 \
    -j RETURN

Телевизор должен нормально получить адрес, DNS и обычный шлюз от домашнего маршрутизатора. Уже после этого политика маршрутизации перенаправит его интернет-трафик на Split Gateway.

Я отдельно не рекомендую пытаться выдавать 192.168.50.254 телевизору в качестве шлюза через какие-нибудь экзотические индивидуальные настройки DHCP на маршрутизаторе. Такая схема может работать, но значительно сложнее сопровождается и создаёт забавный замкнутый круг при получении адреса. Нормальная policy based routing, то есть маршрутизация по источнику, здесь значительно чище.

Где появляется TPROXY

После всех исключений остаётся трафик, который действительно можно передать sing-box:

iptables -t mangle -A SPLITGW \
    -p tcp \
    -j TPROXY \
    --on-port 7893 \
    --tproxy-mark 0x1/0x1

iptables -t mangle -A SPLITGW \
    -p udp \
    -j TPROXY \
    --on-port 7893 \
    --tproxy-mark 0x1/0x1

Здесь пакет получает метку 0x1. Но одной метки недостаточно, потому что ядро Linux всё ещё видит исходный адрес назначения и совершенно справедливо собирается маршрутизировать пакет туда.

Поэтому появляется отдельное правило:

ip rule add \
    fwmark 0x1 \
    lookup 100 \
    prio 100

И отдельная таблица маршрутизации:

ip route add \
    local 0.0.0.0/0 \
    dev lo \
    table 100

Смысл этой конструкции можно перевести с языка Linux networking примерно так: если пакет имеет метку 0x1, не отправляй его обычным маршрутом, а считай адрес назначения локальным и доставь пакет на эту машину. После этого его получает sing-box, слушающий порт 7893.

Именно поэтому TPROXY отличается от обычного перенаправления порта. Прокси видит исходное назначение соединения и может принимать решение на основании реального адреса и обнаруженного протокола.

rp_filter и асимметричная маршрутизация

Есть ещё одна настройка, которую легко пропустить:

sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w net.ipv4.conf.eth0.rp_filter=2

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

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

IP forwarding тоже должен быть включён:

sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1

Для постоянной настройки IPv4 исходный сценарий дополнительно обновляет /etc/sysctl.conf:

net.ipv4.ip_forward = 1

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

Полный скрипт настройки iptables

В результате основная часть исходного setup-iptables.sh выглядит следующим образом:

#!/bin/sh

set -e

TPROXY_PORT="7893"
TPROXY_MARK="0x1"
TPROXY_TABLE="100"
LAN_IF="eth0"

log() {
    echo "[splitgw] $*"
}

log "Enabling IP forwarding..."

sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1

sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w "net.ipv4.conf.${LAN_IF}.rp_filter=2"

log "Setting up policy routing table ${TPROXY_TABLE}..."

ip rule del fwmark "$TPROXY_MARK" \
    lookup "$TPROXY_TABLE" 2>/dev/null || true

ip rule add \
    fwmark "$TPROXY_MARK" \
    lookup "$TPROXY_TABLE" \
    prio 100

ip route flush table "$TPROXY_TABLE" 2>/dev/null || true

ip route add \
    local 0.0.0.0/0 \
    dev lo \
    table "$TPROXY_TABLE"

log "Creating SPLITGW iptables chain..."

iptables -t mangle -N SPLITGW 2>/dev/null || \
    iptables -t mangle -F SPLITGW

iptables -t mangle -A SPLITGW -i lo -j RETURN

iptables -t mangle -A SPLITGW \
    -m mark --mark "$TPROXY_MARK" \
    -j RETURN

iptables -t mangle -A SPLITGW \
    -p tcp --dport "$TPROXY_PORT" \
    -j RETURN

iptables -t mangle -A SPLITGW \
    -p udp --dport "$TPROXY_PORT" \
    -j RETURN

iptables -t mangle -A SPLITGW -d 127.0.0.0/8 -j RETURN
iptables -t mangle -A SPLITGW -d 224.0.0.0/4 -j RETURN
iptables -t mangle -A SPLITGW -d 240.0.0.0/4 -j RETURN

# DHCP
iptables -t mangle -A SPLITGW \
    -p udp --sport 68 --dport 67 \
    -j RETURN

iptables -t mangle -A SPLITGW \
    -p udp -d 192.168.50.1 --dport 67 \
    -j RETURN

iptables -t mangle -A SPLITGW \
    -p udp -d 255.255.255.255 --dport 67 \
    -j RETURN

# Private networks
iptables -t mangle -A SPLITGW -d 10.0.0.0/8 -j RETURN
iptables -t mangle -A SPLITGW -d 172.16.0.0/12 -j RETURN
iptables -t mangle -A SPLITGW -d 192.168.0.0/16 -j RETURN
iptables -t mangle -A SPLITGW -d 169.254.0.0/16 -j RETURN

# Home WAN
iptables -t mangle -A SPLITGW \
    -d 203.0.113.10/32 \
    -j RETURN

# TCP
iptables -t mangle -A SPLITGW \
    -p tcp \
    -j TPROXY \
    --on-port "$TPROXY_PORT" \
    --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"

# UDP
iptables -t mangle -A SPLITGW \
    -p udp \
    -j TPROXY \
    --on-port "$TPROXY_PORT" \
    --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"

for SOURCE in \
    192.168.50.84/32 \
    192.168.50.15/32 \
    192.168.50.208/32
do
    while iptables -t mangle \
        -D PREROUTING \
        -i "$LAN_IF" \
        -s "$SOURCE" \
        -j SPLITGW 2>/dev/null
    do
        :
    done

    iptables -t mangle \
        -A PREROUTING \
        -i "$LAN_IF" \
        -s "$SOURCE" \
        -j SPLITGW

    while iptables -D FORWARD \
        -s "$SOURCE" \
        -j ACCEPT 2>/dev/null
    do
        :
    done

    while iptables -D FORWARD \
        -d "$SOURCE" \
        -j ACCEPT 2>/dev/null
    do
        :
    done

    iptables -I FORWARD 1 \
        -s "$SOURCE" \
        -j ACCEPT

    iptables -I FORWARD 1 \
        -d "$SOURCE" \
        -j ACCEPT
done

log "TPROXY rules applied."

Обратите внимание, что здесь намеренно нет RETURN для ESTABLISHED. Это очень заманчивое «улучшение», которое периодически появляется при оптимизации правил: SYN проходит через sing-box, соединение становится установленным, а последующие пакеты начинают обходить прокси. В результате соединение разваливается настолько красиво, что некоторое время начинаешь подозревать MTU, QUIC, провайдера, Луну и собственные жизненные решения.

Как sing-box решает, что отправлять через Hysteria2

Теперь пакет оказался внутри sing-box. Следующая задача - понять, куда его отправлять.

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

{
  "ip_is_private": true,
  "action": "route",
  "outbound": "direct"
}

Затем проверяются домены сервисов, которые должны использовать Hysteria2:

{
  "domain_keyword": [
    "youtube",
    "googlevideo",
    "ytimg",
    "telegram",
    "facebook",
    "instagram",
    "linkedin",
    "twitter",
    "discord",
    "reddit",
    "openai.com",
    "chatgpt.com"
  ],
  "action": "route",
  "outbound": "proxy"
}

Для коротких и хорошо известных доменов используются suffix-правила:

{
  "domain_suffix": [
    ".t.me",
    ".telegram.org",
    ".telegram.me",
    ".telegram-cdn.org",
    ".cdn-telegram.org",
    ".facebook.com",
    ".instagram.com",
    ".x.com",
    ".twitter.com",
    ".discord.com",
    ".reddit.com"
  ],
  "action": "route",
  "outbound": "proxy"
}

sing-box поддерживает сопоставление по domain, domain_suffix, domain_keyword и IP/CIDR, поэтому для такой маршрутизации не требуется старая база GeoSite. Более того, старые geosite и geoip были объявлены устаревшими ещё раньше и удалялись из современной конфигурационной модели, поэтому использование явных правил и rule-set сегодня выглядит значительно здоровее.

Но одного имени домена недостаточно.

Telegram иногда приходит без красивого имени

Прозрачный прокси не всегда получает удобное имя api.telegram.org. Некоторые клиенты могут подключаться непосредственно к IP-адресам Telegram, и тогда никакой domain_suffix уже не поможет.

Поэтому в конфигурации есть IP-префиксы Telegram:

splitgw_proxy_ip_cidrs:
  - "91.108.4.0/22"
  - "91.108.8.0/22"
  - "91.108.12.0/22"
  - "91.108.16.0/22"
  - "91.108.20.0/22"
  - "91.108.56.0/22"
  - "91.105.192.0/23"
  - "149.154.160.0/20"
  - "185.76.151.0/24"
  - "95.161.64.0/20"

А маршрут выглядит так:

{
  "ip_cidr": [
    "91.108.4.0/22",
    "91.108.8.0/22",
    "91.108.12.0/22",
    "91.108.16.0/22",
    "91.108.20.0/22",
    "91.108.56.0/22",
    "91.105.192.0/23",
    "149.154.160.0/20",
    "185.76.151.0/24"
  ],
  "action": "route",
  "outbound": "proxy"
}

Это особенно полезно не столько для Bot API, сколько для обычных клиентов Telegram. Bot API работает через HTTPS и обычно даёт нам нормальное доменное имя, а клиентские протоколы могут вести себя иначе.

Российский трафик оставляем дома

Обратная группа правил определяет то, что точно не нужно отправлять через VPS:

splitgw_ru_extra_suffixes:
  - ".ru"
  - ".рф"
  - ".su"

splitgw_ru_extra_keywords:
  - "yandex"
  - "kinopoisk"
  - "mail.ru"
  - "ok.ru"
  - "vk.com"
  - "sberbank"
  - "tinkoff"
  - "gosuslugi"
  - "mos.ru"
  - "avito"
  - "hh.ru"
  - "wildberries"
  - "ozon"

В sing-box это объединяется логическим правилом:

{
  "type": "logical",
  "mode": "or",
  "rules": [
    {
      "domain_suffix": [
        ".ru",
        ".рф",
        ".su"
      ]
    },
    {
      "domain_keyword": [
        "yandex",
        "kinopoisk",
        "mail.ru",
        "ok.ru",
        "vk.com",
        "sberbank",
        "tinkoff",
        "gosuslugi",
        "mos.ru",
        "avito",
        "hh.ru",
        "wildberries",
        "ozon"
      ]
    }
  ],
  "action": "route",
  "outbound": "direct"
}

А финальный маршрут остаётся прямым:

"final": "direct"

Это важный архитектурный выбор. Hysteria2 здесь является исключением, а не default route.

Моё непопулярное мнение заключается в том, что «завернуть весь интернет в VPN, а потом писать исключения» для домашней и серверной инфраструктуры чаще всего является ленивой архитектурой. Чем меньше трафика зависит от дополнительного внешнего контура, тем меньше вещей одновременно сломается в три часа ночи.

Если Hysteria2 исчезнет полностью, перестанут работать только сервисы, которым действительно требовался этот маршрут. Plex продолжит показывать фильмы, Home Assistant продолжит включать свет, российские сервисы продолжат ходить напрямую, а DNS локальной сети не отправится путешествовать через Амстердам.

DNS является частью маршрута

Можно идеально настроить маршрутизацию пакетов и всё равно получить странный результат, если забыть про DNS. Именно поэтому в Split Gateway DNS разделён вместе с трафиком.

Конфигурация содержит три сервера:

"dns": {
  "servers": [
    {
      "type": "tls",
      "tag": "dns-proxy",
      "server": "8.8.8.8",
      "domain_resolver": "dns-direct",
      "detour": "proxy"
    },
    {
      "type": "udp",
      "tag": "dns-direct",
      "server": "8.8.4.4"
    },
    {
      "type": "udp",
      "tag": "dns-ru",
      "server": "192.168.50.1"
    }
  ]
}

Современный формат sing-box позволяет описывать DNS-серверы типами tls, udp, https, tcp и другими вариантами, а правила DNS используют те же доменные признаки. DNS over TLS является отдельным штатным типом DNS-сервера.

Для проксируемых сервисов запрос отправляется через Hysteria2:

{
  "domain_suffix": [
    ".t.me",
    ".telegram.org",
    ".telegram.me"
  ],
  "action": "route",
  "server": "dns-proxy"
}

Для российских зон используется домашний DNS:

{
  "domain_suffix": [
    ".ru",
    ".рф",
    ".su"
  ],
  "action": "route",
  "server": "dns-ru"
}

А всё остальное резолвится напрямую:

"final": "dns-direct"

Кроме того, для прозрачного трафика включён DNS hijack:

{
  "protocol": "dns",
  "action": "hijack-dns"
}

И обратное сопоставление:

"reverse_mapping": true

В результате sing-box может связать полученный IP с доменом и принять более разумное решение о маршруте.

Несколько Hysteria2 вместо одного

Самая интересная часть схемы начинается там, где появляется больше одного VPS. В предыдущей статье мы уже получили готовые Hysteria2-серверы, поэтому теперь можно описать несколько URI:

vault_hysteria2_splitgw_urls:
  - "hysteria2://splitgw:password@vpn-nl.example.com:443#vps-nl"
  - "hysteria2://splitgw:password@vpn-kz.example.com:443#vps-kz"
  - "hysteria2://splitgw:password@vpn-ae.example.com:443#vps-ae"

Если используется Salamander:

vault_hysteria2_splitgw_urls:
  - "hysteria2://splitgw:password@vpn-nl.example.com:443?obfs=salamander&obfs-password=secret#vps-nl"
  - "hysteria2://splitgw:password@vpn-kz.example.com:443?obfs=salamander&obfs-password=secret#vps-kz"
  - "hysteria2://splitgw:password@vpn-ae.example.com:443?obfs=salamander&obfs-password=secret#vps-ae"

Здесь есть небольшой нюанс. Официальная реализация Hysteria2 умеет представлять username:password как userpass, а sing-box ожидает эту комбинацию как фактическое значение поля password. Это отдельно отмечено в документации sing-box.

Поэтому Ansible разбирает URI и собирает:

from urllib.parse import urlparse, parse_qs, unquote

def parse_one(url):
    p = urlparse(url)
    qs = parse_qs(p.query, keep_blank_values=True)

    user = unquote(p.username or "")
    passwd = unquote(p.password or "")

    auth = f"{user}:{passwd}" if passwd else user

    fragment = (
        unquote(p.fragment or "")
        or (p.hostname or "hy2")
    )

    tag = "".join(
        c if c.isalnum() or c in "-_" else "-"
        for c in fragment
    ).strip("-") or "hy2"

    return {
        "tag": tag,
        "host": p.hostname or "",
        "port": p.port or 443,
        "password": auth,
        "insecure": (
            qs.get("insecure", ["0"])[0] == "1"
        ),
        "obfs_type": qs.get("obfs", [""])[0],
        "obfs_password": (
            qs.get("obfs-password", [""])[0]
        ),
        "sni": (
            qs.get("peer", [""])[0]
            or qs.get("sni", [""])[0]
            or (p.hostname or "")
        ),
    }

Из трёх URI появляются три outbound:

{
  "type": "hysteria2",
  "tag": "vps-nl",
  "server": "vpn-nl.example.com",
  "server_port": 443,
  "password": "splitgw:password",
  "tls": {
    "enabled": true,
    "insecure": false,
    "server_name": "vpn-nl.example.com"
  }
}

А затем над ними создаётся общий urltest:

{
  "type": "urltest",
  "tag": "proxy",
  "outbounds": [
    "vps-nl",
    "vps-kz",
    "vps-ae"
  ],
  "url": "https://www.gstatic.com/generate_204",
  "interval": "3m",
  "tolerance": 50,
  "interrupt_exist_connections": true
}

Для остальной конфигурации существует только один маршрут:

proxy

Куда он физически ведёт сейчас - в Нидерланды, Казахстан или ОАЭ - приложению совершенно безразлично.

Почему я не хочу прописывать конкретный VPS в Alertmanager

Можно было бы написать:

Alertmanager
    |
    v
vpn-nl.example.com
    |
    v
Telegram

Работать это будет ровно до первой проблемы с этим сервером. После этого начнётся ручное переключение конфигураций.

Можно сделать умнее:

Alertmanager
    |
    v
Split Gateway
    |
    +--> NL
    |
    +--> KZ
    |
    +--> AE

Именно здесь появляется правильная граница ответственности. Alertmanager должен знать, куда отправить уведомление. Он не должен знать географию транспортной инфраструктуры.

Это та же идея, которую мы постоянно используем в Kubernetes. Приложение обращается к Service, а не хранит список IP всех подов. Здесь роль Service выполняет логический outbound proxy.

Добавляем HTTP-вход для серверных приложений

Исходный Split Gateway использует только TPROXY. Для серверных приложений добавим второй inbound:

{
  "type": "http",
  "tag": "http-in",
  "listen": "0.0.0.0",
  "listen_port": 7890
}

HTTP inbound является штатной возможностью sing-box. Он может принимать обычные HTTP CONNECT соединения и при необходимости поддерживает собственную аутентификацию.

Если шлюз доступен из недоверенной сети, оставлять такой прокси без авторизации нельзя. В этом случае лучше сразу добавить пользователя:

{
  "type": "http",
  "tag": "http-in",
  "listen": "0.0.0.0",
  "listen_port": 7890,
  "users": [
    {
      "username": "telegram",
      "password": "CHANGE_ME"
    }
  ]
}

В моей архитектуре этот порт доступен только из внутренней сети или Kubernetes, поэтому его вообще не нужно публиковать в интернет. На внешнем сетевом экране порт 7890 должен быть закрыт.

Теперь добавим правила:

{
  "inbound": "http-in",
  "ip_is_private": true,
  "action": "route",
  "outbound": "direct"
},
{
  "inbound": "http-in",
  "domain_suffix": [
    ".telegram.org",
    ".telegram.me",
    ".t.me"
  ],
  "action": "route",
  "outbound": "proxy"
}

Если этот HTTP-вход предназначен исключительно для Telegram, я предпочитаю сделать его ещё строже:

{
  "inbound": "http-in",
  "domain_suffix": [
    ".telegram.org",
    ".telegram.me",
    ".t.me"
  ],
  "action": "route",
  "outbound": "proxy"
}

А всё остальное для этого входа можно либо отправлять напрямую, либо блокировать отдельным outbound. Здесь уже вопрос политики безопасности.

Отправляем первое сообщение через Split Gateway

Для проверки нам понадобится Telegram Bot API. Метод sendMessage принимает chat_id и текст сообщения, а Telegram возвращает объект отправленного сообщения при успешном запросе.

Сначала проверим без прокси:

export TELEGRAM_BOT_TOKEN="123456:SECRET"
export TELEGRAM_CHAT_ID="-123456789"

curl -sS \
  -X POST \
  "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
  -H "Content-Type: application/json" \
  -d "{
    \"chat_id\": \"${TELEGRAM_CHAT_ID}\",
    \"text\": \"Hello from infrastructure\"
  }"

Теперь тот же запрос отправим через Split Gateway:

curl -sS \
  --proxy http://192.168.50.254:7890 \
  -X POST \
  "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
  -H "Content-Type: application/json" \
  -d "{
    \"chat_id\": \"${TELEGRAM_CHAT_ID}\",
    \"text\": \"Hello through Split Gateway\"
  }"

Для HTTPS curl создаст CONNECT-туннель через HTTP-прокси. sing-box увидит api.telegram.org, выберет outbound proxy, urltest определит текущий Hysteria2-сервер, и соединение наружу будет установлено уже через выбранный VPS.

Проверить это можно по логам:

journalctl -u singbox -f

Или в Kubernetes:

kubectl -n splitgw \
  logs -l app=splitgw \
  -c singbox \
  -f

На время диагностики можно установить:

splitgw_singbox_log_level: "info"

Но оставлять info на постоянно работающем прозрачном шлюзе я не рекомендую. При активном телевизоре и QUIC количество записей довольно быстро превращает логирование в самостоятельную нагрузочную систему.

В обычной эксплуатации:

splitgw_singbox_log_level: "warn"

выглядит значительно разумнее.

Alertmanager отправляет Telegram через тот же шлюз

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

Конфигурация может выглядеть так:

global:
  resolve_timeout: 5m

route:
  receiver: telegram
  group_by:
    - alertname
    - cluster
    - namespace

  group_wait: 10s
  group_interval: 5m
  repeat_interval: 4h

receivers:
  - name: telegram
    telegram_configs:
      - bot_token: "${TELEGRAM_BOT_TOKEN}"
        chat_id: -123456789
        send_resolved: true

        http_config:
          proxy_url: "http://splitgw.splitgw.svc.cluster.local:7890"

Теперь Alertmanager не интересует, какой VPS работает быстрее. Он знает только внутренний адрес:

splitgw.splitgw.svc.cluster.local:7890

А дальше:

Alertmanager
      |
      | HTTP CONNECT
      v
splitgw:7890
      |
      v
sing-box
      |
      v
urltest
   /  |  \
 NL   KZ  AE
      |
      v
api.telegram.org

Это уже выглядит как нормальная инфраструктурная абстракция.

Service для Kubernetes

Для HTTP-прокси создадим внутренний Service:

apiVersion: v1
kind: Service
metadata:
  name: splitgw
  namespace: splitgw
spec:
  type: ClusterIP

  selector:
    app: splitgw

  ports:
    - name: http-proxy
      port: 7890
      targetPort: 7890
      protocol: TCP

После этого внутри Kubernetes доступен адрес:

splitgw.splitgw.svc.cluster.local:7890

Проверяем:

kubectl run curl-test \
  --rm \
  -it \
  --restart=Never \
  --image=curlimages/curl \
  -- \
  curl \
    --proxy http://splitgw.splitgw.svc.cluster.local:7890 \
    https://api.telegram.org

Если нужен тест именно Bot API:

kubectl run curl-test \
  --rm \
  -it \
  --restart=Never \
  --image=curlimages/curl \
  --env="TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" \
  -- \
  curl \
    --proxy http://splitgw.splitgw.svc.cluster.local:7890 \
    "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"

Токен, разумеется, в реальном кластере должен храниться в Secret, Vault, External Secrets или другом нормальном хранилище секретов. Положить токен бота прямо в Git - очень эффективный способ однажды познакомиться с человеком, который нашёл ваш репозиторий раньше вас.

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

Как выглядит Kubernetes-версия Split Gateway

В исходной реализации используется DaemonSet, потому что шлюз должен работать непосредственно в сетевом пространстве gateway-ноды:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: splitgw
  namespace: splitgw

spec:
  selector:
    matchLabels:
      app: splitgw

  template:
    metadata:
      labels:
        app: splitgw

    spec:
      hostNetwork: true
      dnsPolicy: ClusterFirstWithHostNet

      nodeSelector:
        splitgw: "true"

      priorityClassName: system-node-critical

      tolerations:
        - operator: Exists

      initContainers:
        - name: init-iptables
          image: alpine:latest

          securityContext:
            privileged: true

          command:
            - /bin/sh
            - -c
            - |
              apk add -q iptables ip6tables iproute2
              /config/setup-iptables.sh

          volumeMounts:
            - name: config
              mountPath: /config
              readOnly: true

      containers:
        - name: singbox
          image: ghcr.io/sagernet/sing-box:v1.12.12

          args:
            - run
            - --config
            - /etc/sing-box/config.json
            - --directory
            - /var/lib/sing-box

          securityContext:
            capabilities:
              add:
                - NET_ADMIN
                - NET_RAW
                - NET_BIND_SERVICE

          ports:
            - name: http-proxy
              containerPort: 7890
              protocol: TCP

            - name: tproxy
              containerPort: 7893
              protocol: TCP

            - name: tproxy-udp
              containerPort: 7893
              protocol: UDP

          volumeMounts:
            - name: config
              mountPath: /etc/sing-box
              readOnly: true

            - name: data
              mountPath: /var/lib/sing-box

          livenessProbe:
            exec:
              command:
                - /bin/sh
                - -c
                - >
                  sing-box check
                  --config /etc/sing-box/config.json

            initialDelaySeconds: 10
            periodSeconds: 30
            failureThreshold: 3

          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"

            limits:
              cpu: "500m"
              memory: "512Mi"

      volumes:
        - name: config
          configMap:
            name: splitgw-config
            defaultMode: 0755

        - name: data
          emptyDir: {}

hostNetwork: true здесь не украшение. iptables и policy routing должны работать в сетевом пространстве самой ноды, потому что именно через неё проходит транзитный трафик.

Нода выбирается меткой:

kubectl label node gateway01 splitgw=true

Это позволяет не запускать сетевой шлюз на всех worker-нодах. Если у вас двадцать Kubernetes-узлов, превращать каждый в прозрачный домашний маршрутизатор только потому, что существует DaemonSet, несколько избыточно.

Почему рядом с sing-box живёт второй контейнер

Вот здесь находится одна из деталей, которые появляются только после реальной эксплуатации.

В DaemonSet есть второй контейнер:

- name: iptables-reconcile
  image: alpine:latest

  securityContext:
    privileged: true

  command:
    - /bin/sh
    - -c
    - |
      set -e

      apk add -q iptables ip6tables iproute2

      INTERVAL="30"

      log() {
          echo "[splitgw-reconcile] $*"
      }

      while true; do
          NEED=0

          if ! ip rule show |
              grep -q "fwmark 0x1 lookup 100"
          then
              log "missing fwmark rule"
              NEED=1
          fi

          if ! ip route show table 100 |
              grep -q "local default"
          then
              log "missing route table"
              NEED=1
          fi

          if ! iptables \
              -t mangle \
              -L SPLITGW \
              -n >/dev/null 2>&1
          then
              log "missing SPLITGW chain"
              NEED=1
          fi

          if [ "$NEED" -eq 1 ]; then
              /config/setup-iptables.sh ||
                  log "setup failed"
          fi

          sleep "$INTERVAL"
      done

Зачем он нужен, если initContainer уже всё настроил? Потому что между нашей идеальной декларативной моделью и Linux существует Kubernetes networking.

В k3s сетевые компоненты могут изменять маршрутизацию и правила хоста. Если исчезнет:

fwmark 0x1 lookup 100

цепочка SPLITGW продолжит считать пакеты, TPROXY продолжит их маркировать, но ядро перестанет доставлять их локальному sing-box.

Снаружи это выглядит великолепно:

pod Running
liveness OK
sing-box Running
Hysteria2 OK
iptables counters growing
Internet: нет

Вот после таких ситуаций и появляются reconcile-контейнеры.

Kubernetes научил инфраструктурных инженеров очень полезной мысли: недостаточно один раз создать правильное состояние. Иногда нужно ещё поставить рядом маленького параноика, который каждые 30 секунд проверяет, что никто это состояние не украл.

У меня подобные проверки давно перестали вызывать эстетические сомнения. Если инфраструктурный компонент критически зависит от host-level state, его необходимо периодически сверять с ожидаемым состоянием.

ConfigMap для всей конфигурации

Конфигурацию и скрипты удобно хранить в одном ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: splitgw-config
  namespace: splitgw

data:
  config.json: |
    {
      ...
    }

  setup-iptables.sh: |
    #!/bin/sh
    ...

  teardown-iptables.sh: |
    #!/bin/sh
    ...

В Ansible это генерируется через Jinja2:

data:
  config.json: |
{% filter indent(4, True) %}
{% include 'singbox-config.json.j2' %}
{% endfilter %}

  setup-iptables.sh: |
{% filter indent(4, True) %}
{% include 'setup-iptables.sh.j2' %}
{% endfilter %}

  teardown-iptables.sh: |
{% filter indent(4, True) %}
{% include 'teardown-iptables.sh.j2' %}
{% endfilter %}

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

А если Kubernetes вообще нет

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

Ansible устанавливает зависимости:

- name: Install dependencies
  ansible.builtin.package:
    name:
      - iptables
      - iproute2
      - ca-certificates
      - curl
    state: present

Затем скачивает sing-box, создаёт каталоги:

/etc/sing-box
/var/lib/sing-box
/var/log/sing-box

И устанавливает два systemd-сервиса.

Первый отвечает за сетевые правила:

[Unit]
Description=Split Gateway iptables TPROXY rules
After=network.target
Before=singbox.service

[Service]
Type=oneshot
RemainAfterExit=yes

ExecStart=/etc/sing-box/setup-iptables.sh
ExecStop=/etc/sing-box/teardown-iptables.sh

[Install]
WantedBy=multi-user.target

Второй запускает sing-box:

[Unit]
Description=sing-box transparent proxy (split gateway)
Documentation=https://sing-box.sagernet.org/

After=network.target network-online.target splitgw-rules.service
Wants=network-online.target
Requires=splitgw-rules.service

[Service]
Type=simple
User=root

ExecStart=/usr/local/bin/sing-box run \
    --config /etc/sing-box/config.json \
    --directory /var/lib/sing-box

ExecReload=/bin/kill -HUP $MAINPID

Restart=on-failure
RestartSec=5s

LimitNOFILE=1048576

AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE

StandardOutput=append:/var/log/sing-box/singbox.log
StandardError=append:/var/log/sing-box/singbox.log

[Install]
WantedBy=multi-user.target

Порядок запуска принципиален. Сначала создаются правила маршрутизации, потом запускается sing-box.

После установки проверяем:

systemctl status splitgw-rules
systemctl status singbox

Затем:

ip rule show

Ожидаем что-то вроде:

100: from all fwmark 0x1 lookup 100

Проверяем таблицу:

ip route show table 100

Ожидаем:

local default dev lo scope host

И наконец:

iptables -t mangle -L SPLITGW -v -n

Если телевизор или другой клиент генерирует трафик, счётчики перед TPROXY должны расти.

Docker Compose без Kubernetes

Теперь более интересный вариант. Иногда Kubernetes для одного домашнего gateway действительно напоминает доставку бутерброда карьерным самосвалом. Docker Compose здесь вполне достаточен.

Структура проекта может выглядеть так:

splitgw/
├── docker-compose.yml
├── config/
│   └── config.json
└── scripts/
    ├── setup-iptables.sh
    └── teardown-iptables.sh

docker-compose.yml:

services:

  splitgw-rules:
    image: alpine:3.22

    network_mode: host
    privileged: true

    volumes:
      - ./scripts:/config:ro

    command:
      - /bin/sh
      - -c
      - |
        apk add --no-cache iptables iproute2
        /config/setup-iptables.sh
        sleep infinity

    restart: unless-stopped

  singbox:
    image: ghcr.io/sagernet/sing-box:v1.12.12

    network_mode: host

    cap_add:
      - NET_ADMIN
      - NET_RAW
      - NET_BIND_SERVICE

    volumes:
      - ./config:/etc/sing-box:ro
      - singbox-data:/var/lib/sing-box

    command:
      - run
      - --config
      - /etc/sing-box/config.json
      - --directory
      - /var/lib/sing-box

    depends_on:
      - splitgw-rules

    restart: unless-stopped

  splitgw-reconcile:
    image: alpine:3.22

    network_mode: host
    privileged: true

    volumes:
      - ./scripts:/config:ro

    command:
      - /bin/sh
      - -c
      - |
        apk add --no-cache iptables iproute2

        while true; do

          NEED=0

          ip rule show |
            grep -q "fwmark 0x1 lookup 100" ||
            NEED=1

          ip route show table 100 |
            grep -q "local default" ||
            NEED=1

          iptables \
            -t mangle \
            -L SPLITGW \
            -n >/dev/null 2>&1 ||
            NEED=1

          if [ "$NEED" -eq 1 ]; then
            echo "[splitgw] restoring rules"
            /config/setup-iptables.sh
          fi

          sleep 30
        done

    restart: unless-stopped

volumes:
  singbox-data:

Здесь используется network_mode: host, потому что sing-box и контейнер с iptables должны работать с сетевым пространством хоста. Контейнер настройки правил получает privileged, поскольку обычных container capabilities для управления всей host network конфигурацией недостаточно.

Запуск:

docker compose up -d

Проверяем:

docker compose ps

Логи:

docker compose logs -f singbox

Правила:

iptables -t mangle -L SPLITGW -v -n

Маршрутизацию:

ip rule show
ip route show table 100

А HTTP-прокси:

curl \
  --proxy http://127.0.0.1:7890 \
  https://api.telegram.org

Если sing-box работает на отдельном gateway:

curl \
  --proxy http://192.168.50.254:7890 \
  https://api.telegram.org

Таким образом Docker Compose версия функционально почти не отличается от Kubernetes. Различается только механизм жизненного цикла процессов.

Пример полного config.json

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

{
  "log": {
    "level": "warn",
    "timestamp": true
  },

  "dns": {
    "servers": [
      {
        "type": "tls",
        "tag": "dns-proxy",
        "server": "8.8.8.8",
        "domain_resolver": "dns-direct",
        "detour": "proxy"
      },
      {
        "type": "udp",
        "tag": "dns-direct",
        "server": "8.8.4.4"
      },
      {
        "type": "udp",
        "tag": "dns-ru",
        "server": "192.168.50.1"
      }
    ],

    "rules": [
      {
        "domain_keyword": [
          "telegram",
          "youtube",
          "googlevideo",
          "facebook",
          "instagram",
          "discord",
          "reddit"
        ],
        "action": "route",
        "server": "dns-proxy"
      },
      {
        "domain_suffix": [
          ".telegram.org",
          ".telegram.me",
          ".t.me"
        ],
        "action": "route",
        "server": "dns-proxy"
      },
      {
        "domain_suffix": [
          ".ru",
          ".рф",
          ".su"
        ],
        "action": "route",
        "server": "dns-ru"
      }
    ],

    "final": "dns-direct",
    "reverse_mapping": true
  },

  "inbounds": [
    {
      "type": "tproxy",
      "tag": "tproxy-in",
      "listen": "0.0.0.0",
      "listen_port": 7893,
      "tcp_fast_open": true,
      "udp_fragment": true,
      "udp_timeout": "5m"
    },

    {
      "type": "http",
      "tag": "http-in",
      "listen": "0.0.0.0",
      "listen_port": 7890
    }
  ],

  "outbounds": [
    {
      "type": "hysteria2",
      "tag": "vps-nl",
      "server": "vpn-nl.example.com",
      "server_port": 443,
      "password": "splitgw:CHANGE_ME",
      "tls": {
        "enabled": true,
        "insecure": false,
        "server_name": "vpn-nl.example.com"
      }
    },

    {
      "type": "hysteria2",
      "tag": "vps-kz",
      "server": "vpn-kz.example.com",
      "server_port": 443,
      "password": "splitgw:CHANGE_ME",
      "tls": {
        "enabled": true,
        "insecure": false,
        "server_name": "vpn-kz.example.com"
      }
    },

    {
      "type": "hysteria2",
      "tag": "vps-ae",
      "server": "vpn-ae.example.com",
      "server_port": 443,
      "password": "splitgw:CHANGE_ME",
      "tls": {
        "enabled": true,
        "insecure": false,
        "server_name": "vpn-ae.example.com"
      }
    },

    {
      "type": "urltest",
      "tag": "proxy",
      "outbounds": [
        "vps-nl",
        "vps-kz",
        "vps-ae"
      ],
      "url": "https://www.gstatic.com/generate_204",
      "interval": "3m",
      "tolerance": 50,
      "interrupt_exist_connections": true
    },

    {
      "type": "direct",
      "tag": "direct"
    }
  ],

  "route": {
    "rules": [
      {
        "inbound": "tproxy-in",
        "action": "resolve",
        "strategy": "prefer_ipv4"
      },

      {
        "inbound": "tproxy-in",
        "action": "sniff"
      },

      {
        "protocol": "dns",
        "action": "hijack-dns"
      },

      {
        "ip_is_private": true,
        "action": "route",
        "outbound": "direct"
      },

      {
        "domain_keyword": [
          "youtube",
          "googlevideo",
          "ytimg",
          "telegram",
          "facebook",
          "instagram",
          "linkedin",
          "twitter",
          "discord",
          "reddit"
        ],
        "action": "route",
        "outbound": "proxy"
      },

      {
        "domain_suffix": [
          ".t.me",
          ".telegram.org",
          ".telegram.me",
          ".telegram-cdn.org",
          ".cdn-telegram.org"
        ],
        "action": "route",
        "outbound": "proxy"
      },

      {
        "ip_cidr": [
          "91.108.4.0/22",
          "91.108.8.0/22",
          "91.108.12.0/22",
          "91.108.16.0/22",
          "91.108.20.0/22",
          "91.108.56.0/22",
          "91.105.192.0/23",
          "149.154.160.0/20",
          "185.76.151.0/24"
        ],
        "action": "route",
        "outbound": "proxy"
      },

      {
        "type": "logical",
        "mode": "or",
        "rules": [
          {
            "domain_suffix": [
              ".ru",
              ".рф",
              ".su"
            ]
          },
          {
            "domain_keyword": [
              "yandex",
              "kinopoisk",
              "mail.ru",
              "ok.ru",
              "vk.com",
              "sberbank",
              "gosuslugi",
              "avito",
              "ozon"
            ]
          }
        ],
        "action": "route",
        "outbound": "direct"
      }
    ],

    "final": "direct",
    "auto_detect_interface": true,
    "default_domain_resolver": "dns-direct"
  }
}

Перед запуском обязательно проверяем конфигурацию:

sing-box check \
  --config /etc/sing-box/config.json

У sing-box есть штатная команда check, поэтому запускать новый конфиг сразу в production без проверки совершенно необязательно.

Переменные Ansible

В моей роли все стендозависимые параметры вынесены из шаблонов:

addon_splitgw: true

splitgw_deploy_mode: "k8s"

splitgw_tv_sources:
  - "192.168.50.84/32"
  - "192.168.50.15/32"
  - "192.168.50.208/32"

splitgw_lan_interface: ""

splitgw_router_ip: "192.168.50.1"

splitgw_direct_destinations:
  - "203.0.113.10/32"

splitgw_tproxy_port: 7893
splitgw_tproxy_mark: "0x1"
splitgw_tproxy_table: 100

splitgw_singbox_log_level: "warn"

splitgw_singbox_image:
  "ghcr.io/sagernet/sing-box:v1.12.12"

splitgw_urltest_url:
  "https://www.gstatic.com/generate_204"

splitgw_urltest_interval: "3m"
splitgw_urltest_tolerance: 50
splitgw_urltest_interrupt: true

splitgw_k8s_namespace: "splitgw"
splitgw_k8s_node_label: "splitgw=true"

splitgw_iptables_reconcile_interval: 30

Секреты лежат отдельно:

vault_hysteria2_splitgw_urls:
  - >-
    hysteria2://splitgw:password@vpn-nl.example.com:443#vps-nl

  - >-
    hysteria2://splitgw:password@vpn-kz.example.com:443#vps-kz

  - >-
    hysteria2://splitgw:password@vpn-ae.example.com:443#vps-ae

Такую структуру намного проще сопровождать. Роль описывает механику, group_vars описывает конкретный стенд, а Vault содержит секреты.

Playbook остаётся почти смешным

Точка входа получается небольшой:

---
- name: Deploy Split Gateway
  hosts: splitgw
  become: true

  roles:
    - role: splitgw

Inventory:

[splitgw]
gateway01 \
  ansible_host=192.168.50.254 \
  ansible_user=devops

Для systemd:

splitgw_deploy_mode: "host"

Для Kubernetes:

splitgw_deploy_mode: "k8s"

И это один из моментов, которые мне особенно нравятся в этой реализации. Сетевая логика остаётся одной и той же, меняется только способ запуска.

Как проверить всю цепочку

Начинать диагностику с Telegram я не рекомендую. Когда одновременно участвуют DNS, TLS, Hysteria2, urltest, HTTP proxy, Kubernetes Service и Bot API, сообщение «что-то не отправилось» обладает примерно нулевой диагностической ценностью.

Сначала проверяем sing-box:

sing-box check \
  --config /etc/sing-box/config.json

Затем процесс:

systemctl status singbox

Или:

kubectl -n splitgw get pods -o wide

После этого проверяем policy routing:

ip rule show

Нужна строка:

fwmark 0x1 lookup 100

Затем:

ip route show table 100

Нужен локальный default route:

local default dev lo scope host

После этого смотрим iptables:

iptables \
  -t mangle \
  -L SPLITGW \
  -v \
  -n

Если устройство генерирует трафик, счётчики должны увеличиваться.

Для HTTP proxy проверяем:

curl \
  -v \
  --proxy http://192.168.50.254:7890 \
  https://api.telegram.org

Затем:

curl \
  -sS \
  --proxy http://192.168.50.254:7890 \
  "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"

И только потом отправляем сообщение:

curl \
  -sS \
  --proxy http://192.168.50.254:7890 \
  -X POST \
  "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
  -H "Content-Type: application/json" \
  -d "{
    \"chat_id\": \"${TELEGRAM_CHAT_ID}\",
    \"text\": \"Split Gateway works\"
  }"

Так диагностика превращается из гадания в последовательность проверяемых слоёв.

Отправка файла

Telegram Bot API позволяет отправлять документы методом sendDocument. На момент написания статьи документация Telegram указывает поддержку отправки ботом файлов до 50 МБ через этот метод.

Например:

curl \
  --proxy http://192.168.50.254:7890 \
  -F "chat_id=${TELEGRAM_CHAT_ID}" \
  -F "document=@backup-report.txt" \
  "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument"

Здесь важно понимать маршрут байтов:

backup-report.txt
      |
      v
curl
      |
      v
Split Gateway HTTP proxy
      |
      v
sing-box
      |
      v
urltest
      |
      v
Hysteria2 VPS
      |
      v
Telegram

Файл действительно проходит через наш транспорт.

Если же в Bot API передать Telegram внешнюю URL-ссылку на файл, дальнейшая загрузка ресурса может выполняться уже инфраструктурой Telegram. Это совершенно другая сетевая модель, поэтому при диагностике подобных интеграций всегда полезно понимать, кто именно физически скачивает содержимое.

Python тоже ничего особенного знать не должен

Для приложения на Python схема может быть ещё проще. Прокси задаётся на уровне HTTP-клиента или переменных окружения:

export HTTPS_PROXY="http://splitgw.splitgw.svc.cluster.local:7890"
export HTTP_PROXY="http://splitgw.splitgw.svc.cluster.local:7890"

export NO_PROXY="
127.0.0.1,
localhost,
.svc,
.cluster.local,
10.0.0.0/8,
172.16.0.0/12,
192.168.0.0/16
"

Приложение продолжает обращаться к:

https://api.telegram.org

Оно не знает про Hysteria2. Не знает про Нидерланды. Не знает про Казахстан. Не знает про urltest.

Именно такой уровень абстракции мне кажется правильным.

Почему NO_PROXY здесь важнее, чем кажется

Если просто добавить:

HTTPS_PROXY=http://splitgw:7890

можно случайно отправить через прокси обращения приложения к внутренним сервисам.

Например:

postgres.default.svc.cluster.local
redis.default.svc.cluster.local
vault.vault.svc.cluster.local
prometheus.monitoring.svc.cluster.local

Поэтому обязательно задаём обход:

NO_PROXY=127.0.0.1,localhost,.svc,.cluster.local,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16

Да, sing-box тоже умеет отправить private IP напрямую. Но я предпочитаю вообще не заводить внутренний трафик в внешний proxy pipeline, если этого можно избежать.

Защита в несколько уровней здесь полезна:

Приложение
   |
   +--> NO_PROXY
   |
   v
HTTP proxy
   |
   +--> ip_is_private -> direct
   |
   v
Hysteria2

Если первый уровень ошибётся, второй всё ещё не отправит PostgreSQL путешествовать по миру.

Почему здесь нет отдельного клиента Hysteria2

Это один из частых вопросов. В классической архитектуре хочется запустить:

hysteria client

а sing-box использовать только как маршрутизатор.

Технически можно. Практической необходимости здесь нет.

Hysteria2 является нативным outbound sing-box, поэтому цепочка:

application
    |
sing-box
    |
hysteria2
    |
VPS

работает внутри одного процесса.

Меньше процессов означает меньше конфигураций, меньше systemd units, меньше health checks и меньше мест, где можно получить рассинхронизацию. Для маленького домашнего сервера это удобно, а для Kubernetes ещё важнее.

Что происходит при падении одного VPS

Предположим, сейчас лучший маршрут:

proxy -> vps-nl

Нидерландский сервер становится недоступен. Следующая проверка urltest обнаруживает проблему и выбирает другой живой outbound:

proxy -> vps-kz

Для приложения ничего не меняется:

http://splitgw:7890

Для Alertmanager ничего не меняется:

proxy_url:
  http://splitgw.splitgw.svc.cluster.local:7890

Для телевизора ничего не меняется вообще. Он даже не знает, что где-то существует Hysteria2.

Если включено:

"interrupt_exist_connections": true

существующие соединения могут быть прерваны при переключении. Для уведомлений Telegram это обычно совершенно приемлемо: следующий HTTP-запрос установит новое соединение через выбранный сервер.

Для длинного видеопотока переключение может быть заметнее. Здесь уже приходится выбирать между быстрым failover и сохранением существующих соединений.

Что будет, если умрёт весь Hysteria2

Это более интересный сценарий. Если недоступны все серверы urltest, трафик, явно направленный в outbound proxy, работать не сможет.

Но поскольку:

"final": "direct"

остальная сеть продолжит работать напрямую.

Именно здесь проявляется преимущество split tunnel. Мы потеряли внешний маршрут, а не интернет целиком.

При full-tunnel схема выглядела бы так:

Hysteria2 down
      |
      v
весь интернет down

В нашей архитектуре:

Hysteria2 down
      |
      +--> Telegram down
      +--> YouTube proxy route down
      |
      +--> LAN works
      +--> Plex works
      +--> RU works
      +--> direct Internet works

Для домашней инфраструктуры это гораздо более приятная деградация.

Что мониторить

Сам факт Running у контейнера мало что говорит. Можно иметь живой sing-box, работающий iptables, существующий Hysteria2 и при этом не иметь рабочего end-to-end маршрута.

Поэтому я бы контролировал минимум несколько уровней:

process
    |
    v
sing-box alive
    |
    v
config valid
    |
    v
ip rule exists
    |
    v
routing table exists
    |
    v
SPLITGW chain exists
    |
    v
proxy TCP port available
    |
    v
Telegram API reachable

Простейшая проверка:

curl \
  --fail \
  --silent \
  --show-error \
  --max-time 10 \
  --proxy http://127.0.0.1:7890 \
  https://api.telegram.org

Для более серьёзной инфраструктуры я бы добавил отдельный blackbox probe через proxy и метрику состояния policy routing.

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

Почему Kubernetes-вариант требует privileged

Это один из минусов решения, который нельзя прятать под ковёр. Init-контейнер и reconcile-контейнер имеют:

securityContext:
  privileged: true

Причина проста: они изменяют host iptables, ip rule и таблицы маршрутизации.

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

Поэтому я бы обязательно применил несколько ограничений. Образ должен быть фиксированной версии, ConfigMap должен изменяться только через контролируемый GitOps-процесс, namespace должен иметь строгий RBAC, а запуск DaemonSet необходимо ограничить выделенными gateway-нодами.

Это не тот компонент, который стоит разрешить редактировать всем разработчикам пространства имён.

Почему я фиксирую версию sing-box

В Kubernetes-конфигурации используется:

splitgw_singbox_image:
  "ghcr.io/sagernet/sing-box:v1.12.12"

а не:

latest

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

В sing-box активно развивается модель DNS, route actions, TUN и других подсистем. Например, старые geosite/geoip были объявлены устаревшими и удалялись из новой конфигурационной модели, а DNS в 1.12 получил новую типизированную схему серверов.

Поэтому версия должна обновляться осознанно:

v1.12.12
    |
test
    |
sing-box check
    |
staging
    |
gateway

а не:

latest
    |
production
    |
интересный вечер

В host-варианте исходная роль умеет автоматически получать последний release, но для production я всё равно предпочитаю задавать splitgw_singbox_version явно после проверки новой версии.

Molecule здесь не роскошь

В исходном проекте роль имеет Molecule-тесты. Они проверяют не сам интернет, а результат рендеринга конфигурации.

Например:

- name: Assert tproxy inbound is present
  ansible.builtin.assert:
    that:
      - >-
        config_json.inbounds |
        selectattr('type', 'equalto', 'tproxy') |
        list |
        length >= 1

Проверяется порт:

- name: Assert tproxy port is set
  ansible.builtin.assert:
    that:
      - >-
        config_json.inbounds |
        selectattr('type', 'equalto', 'tproxy') |
        selectattr('listen_port', 'equalto', 7893) |
        list |
        length >= 1

Проверяются Hysteria2 outbounds:

- name: Assert hysteria2 outbounds are present
  ansible.builtin.assert:
    that:
      - >-
        config_json.outbounds |
        selectattr('type', 'equalto', 'hysteria2') |
        list |
        length == 3

      - >-
        config_json.outbounds |
        selectattr('type', 'equalto', 'urltest') |
        list |
        length == 1

И даже критические bypass-правила:

- name: Assert iptables bypasses RFC1918 and home WAN
  ansible.builtin.assert:
    that:
      - >-
        '192.168.0.0/16' in
        (iptables_raw.content | b64decode)

      - >-
        '203.0.113.10/32' in
        (iptables_raw.content | b64decode)

Такие тесты кажутся избыточными ровно до первого изменения Jinja2-шаблона, после которого исчезает один RETURN, и телевизор начинает отправлять локальный Plex через внешний VPS.

Инфраструктурный код заслуживает тестов ничуть не меньше прикладного.

Что мне в этой архитектуре нравится

Первый плюс - приложения отделены от транспорта. Alertmanager знает HTTP proxy, телевизор вообще ничего не знает, а список Hysteria2-серверов существует только внутри gateway.

Второй плюс - одна инфраструктура используется сразу несколькими типами клиентов. Прозрачный TPROXY обслуживает устройства, которые невозможно или неудобно настраивать, а HTTP proxy обслуживает серверные приложения.

Третий плюс - нормальная деградация. Hysteria2 не является default route для всей сети.

Четвёртый плюс - автоматический выбор внешней точки. Добавить ещё один VPS можно, просто дописав URI:

vault_hysteria2_splitgw_urls:
  - "..."
  - "..."
  - "..."
  - "hysteria2://splitgw:password@vpn-new.example.com:443#new"

После следующего деплоя он попадёт в urltest.

Пятый плюс - одинаковая логика для bare metal, виртуальной машины, Docker и Kubernetes. Меняется оболочка запуска, но не архитектура маршрутизации.

Что мне в этой архитектуре не нравится

Первый минус - iptables и policy routing требуют понимания Linux networking. Когда что-то ломается, привычного kubectl logs иногда недостаточно.

Второй минус - Kubernetes-реализация требует доступа к host networking и привилегированного контейнера. Это серьёзное архитектурное решение, которое должно быть осознанным.

Третий минус - списки доменов и IP необходимо сопровождать. Интернет-сервисы меняют CDN, адресные диапазоны и домены.

Четвёртый минус - TPROXY остаётся довольно низкоуровневой технологией. В современных версиях sing-box существует TUN с auto_redirect, причём документация прямо рекомендует этот механизм на Linux как более производительный и менее конфликтующий с Docker bridge networks.

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

Кладбище технологий в моей инфраструктуре и без того достаточно большое. Там уже лежат старые OpenVPN-конфиги, несколько поколений Docker networking, забытые iptables-save, пара systemd unit-файлов с комментариями «temporary» пятилетней давности и один особенно дорогой сердцу Bash-скрипт, который никто не удаляет исключительно из суеверия.

Когда я бы выбрал TUN вместо TPROXY

Для новой системы, особенно если она работает только на одном современном Linux-хосте, я бы обязательно рассмотрел tun + auto_route + auto_redirect. Современный sing-box прямо рекомендует auto_redirect на Linux и отмечает, что этот механизм обеспечивает более высокую производительность и меньше конфликтует с Docker bridge networking.

Но миграцию существующего шлюза я бы делал только после полноценного тестирования UDP, DNS, локальной сети, hairpin NAT и поведения Kubernetes CNI. Сетевой шлюз - плохое место для архитектурного перфекционизма ради архитектурного перфекционизма.

Иногда скучная технология, поведение которой вы знаете до последнего fwmark, значительно ценнее новой и более красивой.

Одна неприятная история про «оно же Running»

Однажды после очередного изменения сетевой части кластера Split Gateway выглядел абсолютно здоровым. Pod был Running, sing-box check проходил, Hysteria2 устанавливал соединение, в логах не было ошибок, а счётчики iptables росли.

Телевизор при этом сообщал, что интернета нет.

Минут через двадцать стало понятно, что исчезло правило:

fwmark 0x1 lookup 100

Пакет попадал в SPLITGW, получал метку, но таблица policy routing больше не отправляла его локально. Для iptables всё было прекрасно, для sing-box тоже всё было прекрасно, потому что он этих пакетов вообще не видел.

Именно после этого reconcile-контейнер окончательно получил право на существование.

Это хороший пример того, почему наблюдаемость сетевого сервиса нельзя строить вокруг состояния одного процесса. Сервисом является вся цепочка.

Ещё одна история про DNS

В другой раз внешний маршрут прекрасно работал через Hysteria2, но отдельные видеосервисы продолжали вести себя странно. TCP уходил через зарубежный VPS, адрес выхода был правильным, соединение устанавливалось, но клиент периодически получал неудобные CDN endpoints.

Причина оказалась банальной: DNS продолжал разрешаться через домашнего провайдера.

После разделения DNS:

proxy domains -> DNS through Hysteria2
RU domains    -> home router
other         -> direct DNS

поведение стало значительно предсказуемее.

С тех пор я стараюсь вообще не рассматривать DNS как отдельный вспомогательный сервис. В split-routing DNS является частью маршрутизации.

Как выглядит итоговая цепочка Telegram

Теперь можно собрать весь путь одного уведомления.

Alertmanager создаёт сообщение:

DiskSpaceLow

Его HTTP-клиент видит:

proxy_url:
  http://splitgw.splitgw.svc.cluster.local:7890

Запрос идёт:

Alertmanager
      |
      v
Kubernetes Service
      |
      v
sing-box HTTP inbound :7890
      |
      v
api.telegram.org
      |
      v
route rule
      |
      v
outbound "proxy"
      |
      v
urltest
      |
      +--> NL
      +--> KZ
      +--> AE
      |
      v
best Hysteria2
      |
      v
Telegram Bot API
      |
      v
чат

При этом Alertmanager не знает ни одного Hysteria2 URI.

Telegram не знает ничего о Kubernetes.

Hysteria2 ничего не знает про Alertmanager.

Именно так мне нравится строить инфраструктуру: каждый слой знает только то, что действительно относится к его ответственности.

А телевизор использует тот же самый выход совершенно иначе

У телевизора путь начинается иначе:

Android TV
    |
    v
Keenetic
    |
    | policy route by source
    v
gateway01
    |
    v
iptables PREROUTING
    |
    v
SPLITGW
    |
    v
TPROXY :7893
    |
    v
sing-box
    |
    +--> YouTube -> proxy
    |
    +--> Telegram -> proxy
    |
    +--> .ru -> direct
    |
    +--> LAN -> direct
    |
    +--> other -> direct

Дальше proxy ведёт ровно в тот же urltest, который используется серверными приложениями.

Получается одна транспортная инфраструктура и две совершенно разные точки входа:

                  ┌── TPROXY :7893
                  │
clients ──────────┤
                  │
                  └── HTTP proxy :7890
                            |
                            v
                         routing
                            |
                            v
                         urltest
                            |
                      Hysteria2 x N

Вот это, собственно, и является основной идеей всей конструкции.

Что в итоге получилось

Начиналась эта история с очень простой задачи: дать нескольким устройствам стабильный доступ к отдельным внешним сервисам. Потом выяснилось, что те же Hysteria2-серверы полезно использовать для Telegram Bot API. Затем понадобилась отказоустойчивость между несколькими VPS. После этого появился DNS split routing. Потом Kubernetes однажды потерял policy rule, и рядом поселился reconcile-контейнер. Так обычно и растёт настоящая инфраструктура - не из архитектурной диаграммы на двадцать компонентов, а из последовательности вполне конкретных эксплуатационных проблем.

При этом итоговая система осталась достаточно простой. На внешних VPS работает Hysteria2. На gateway работает sing-box. urltest выбирает лучший сервер. iptables передаёт прозрачный TCP/UDP в TPROXY. HTTP proxy принимает серверные приложения. DNS следует той же политике маршрутизации, что и пользовательский трафик.

Для Kubernetes всё это запускается через DaemonSet. Для обычного Linux - через systemd. Для небольшого домашнего сервера можно использовать Docker Compose.

А приложениям в итоге остаётся одна простая вещь:

http://splitgw:7890

или вообще ничего, если речь идёт о прозрачном клиенте.

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

Наверное, именно это мне больше всего нравится в Split Gateway. Он не пытается заменить маршрутизатор, VPN, Kubernetes networking и прикладные настройки одновременно. Он решает одну довольно узкую задачу: получает трафик, определяет его назначение и выбирает правильный выход.

И если завтра вместо трёх Hysteria2-серверов станет пять, Alertmanager этого не заметит. Если Нидерланды окажутся медленнее Казахстана, телевизор этого не заметит. Если один VPS исчезнет на техническое обслуживание, приложение не придётся переконфигурировать.

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