Предисловие

В данной статье будет показан пример реализации раздельного туннелирования на домашнем маршрутизаторе. Основная идея заключается в том, чтоб часть трафика шла через удаленный VPS-сервер, на котором развернут Xray + xHTTP, а часть через провайдера. Примеры применения данной технологии оставляю на откуп фантазии читателя, от себя лишь скажу, что статья ни в коем случае не предназначена для обхода каких-либо блокировок и написана исключительно в лабораторных целях. Развертывание Xray на VPS также не является предметом обсуждения в этой статье. Просто предположим, что он у вас есть. В целом будет достаточно даже одного ключа.

В качестве подопытного будет использоваться Keenetic Hopper KN-3810 — добротный гигабитный роутер на АРМ процессоре с прошивкой KeeneticOS 5 версии (да-да, даже не Openwrt). На подопытном будет использоваться Entware/opkg, через которые будет поднят x-ray core. В отличии от sing-box, он поддерживает транспорт xHTTP. Раздельное туннелирование буду делать по принципу DNS-based routing, т.к. у меня нет необходимости маршрутизировать конкретные IP.

Немного терминологии кинетика для тех, кто ранее ничем подобным не занимался:
opkg — это пакетный менеджер Openwrt:
Entware — своего рода репозиторий opkg, который можно накатить поверх прошивки для запуска доп.софта;
Xray core — vpn "клиент", поддерживающие известные протоколы;

Подготовка флешки для Entware/OPKG

Первое, чем нужно заняться, это подготовка флешки, на которую будут установлены Entware/OPKG. Минимальный объем от 2ГБ для базовой установки, но желательно выделить от 8 до 32, чтоб разместить репозиторий пакетов и обновлений без переполнения. Не забываем, что на нее же будут сыпаться кэш, логи и прочий мусор, а также флешка должна выдерживать частые записи/удаления без повреждений. Еще один немаловажный факт, который стоит учесть, что для работы OPKG флешку в роутере придется похоронить. Форматировать следует в FAT32 или EXT4. Keenetic рекомендует использовать формат EXT4, поэтому выбираю его:

  1. Вставляем флешку, определяем командой fdisk -l; В данном случае /dev/sda - моя 8-гиговая флешка;

    Pasted-image-20260307194838.png
    Pasted-image-20260307194838.png
  2. Размонтируем командой umount /dev/sda1;

  3. Форматируем в EX4 командой mkfs.ext4 -L OPKG /dev/sda1;

  4. С помощью команды fsck -f /dev/sda1 проверяем, что все ок, и извлекаем флешку;

    Pasted-image-20260307195732.png
    Pasted-image-20260307195732.png

Установка OPKG

Далее необходимо донастроить флешку и установить OPKG на самом Кинетике. Вставляем ее в USB‑порт, и, если всё сделано правильно, роутер сам распознаёт флешку.
Заходим на роутер через веб‑интерфейс и переходим в «Компоненты операционной системы». В случае с версией прошивки 5.0.7 необходимо найти и активировать чекбоксы следующих компонентов:

  • Поддержка открытых пакетов;

  • Файловая система Ext (для ext4);

  • Интерфейс USB;

  • Поддержка USB-накопителей;

  • Сервер SSH;

  • Block-mount;

Если не находится block-mount - это ок. В разных версиях он называется по-разному, но почти всегда этот компонент уже активирован по умолчанию. После обновляем KeeneticOS и ждем перезагрузку.

Pasted-image-20260307201358.png
Pasted-image-20260307201358.png

Для установки Entware переходим в раздел меню "OPKG", выбираем флешку в выпадающем списке, включаем доступ для пользователя и сохраняем;

Pasted-image-20260307231045.png
Pasted-image-20260307231045.png

Затем локально скачиваем wget https://bin.entware.net/mipselsf-k3.4/installer/mipsel-installer.tar.gz; В веб-морде переходим к Applications, выбираем флешку, создаем папку install, закидываем скаченный файл;

Pasted-image-20260307235118.png
Pasted-image-20260307235118.png

Дальше уже гарантированно потребуется иметь возможность реализовать SSH-подключение. Для этого заходим сперва через telnet, вводим креды, затем выполняем на роутере:

service ssh
system configuration save

После этого можно ходить по ssh admin@192.168.1.1;
Самое время активировать opkg, поэтому выполняем команды:

service opkg
system configuration save

После активации OPKG возвращаемся на веб-морду, снова выбираем флешку, жмем пользователя, жмем сохранить. После этого можно перейти в диагностику и проверить журнал логов на процесс установки.
Корректный лог должен выглядеть примерно так:

Pasted-image-20260308000911.png
Pasted-image-20260308000911.png

Возвращаемся к терминалу и переходим в BusyBox Shell с помощью команды exec /opt/bin/sh и скачиваем актуальные индексы пакетов командой /opt/bin/opkg update;

Pasted-image-20260308125600.png
Pasted-image-20260308125600.png

Затем потребуется установить базовый набор пакетов, который нужен для x-ray core:

/opt/bin/opkg install ca-certificates ca-bundle
/opt/bin/opkg install curl wget
/opt/bin/opkg install ip-full
/opt/bin/opkg install iptables

Установка X-RAY CORE

Для установки можно использ��вать данный скрипт:

cd /opt/tmp
/opt/bin/opkg install unzip
/opt/bin/curl -L -o xray.zip \
  https://github.com/XTLS/Xray-core/releases/download/v26.2.6/Xray-linux-mips32le.zip
rm -rf /opt/tmp/xray && mkdir -p /opt/tmp/xray
/opt/bin/unzip -o xray.zip -d /opt/tmp/xray
cp -f /opt/tmp/xray/xray /opt/bin/xray
chmod +x /opt/bin/xray
/opt/bin/xray version

Затем положим датники в рабочее место:

mkdir -p /opt/etc/xray
cp -f /opt/tmp/xray/geoip.dat /opt/etc/xray/geoip.dat
cp -f /opt/tmp/xray/geosite.dat /opt/etc/xray/geosite.dat
cp -f /opt/tmp/xray/geoip.dat /opt/bin/geoip.dat
cp -f /opt/tmp/xray/geosite.dat /opt/bin/geosite.dat
ls -lh /opt/bin/geoip.dat /opt/bin/geosite.dat

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

Pasted-image-20260308235142.png
Pasted-image-20260308235142.png

Сборка конфига

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

mkdir -p /opt/etc/xray
cat > /opt/etc/xray/config.json <<'JSON'
{
  "log": { "loglevel": "warning" },

  "inbounds": [
    {
      "tag": "tun-in",
      "protocol": "tun",
      "settings": { "name": "xray0", "mtu": 1500 },
      "sniffing": {
        "enabled": true,
        "destOverride": ["http", "tls", "quic"],
        "routeOnly": true
      }
    }
  ],

  "outbounds": [
    { "tag": "direct", "protocol": "freedom" },
    {
      "tag": "proxy",
      "protocol": "vless",
      "settings": {
        "vnext": [
          {
            "address": "ваш домен",
            "port": ваш порт xhttp,
            "users": [
              { "id": "ваш UUID", "encryption": "none" }
            ]
          }
        ]
      },
      "streamSettings": {
        "network": "xhttp",
        "security": "tls",
        "tlsSettings": {
          "serverName": "ваш домен",
          "alpn": ["h2", "http/1.1"],
          "fingerprint": "chrome"
        },
        "xhttpSettings": {
          "host": "ваш домен",
          "path": "/ваш домен",
          "mode": "auto"
        }
      }
    }
  ],

  "routing": {
    "domainStrategy": "AsIs",
    "rules": [
      {
        "type": "field",
        "ip": ["geoip:google"],
        "outboundTag": "proxy"
      },
      {
        "type": "field",
        "domain": [
          "domain:discord.com",
          "domain:discord.gg",
          "domain:discordapp.com",
          "domain:discordapp.net",
          "domain:discordcdn.com",
          "domain:discord.media",

          "domain:soundcloud.com",
          "domain:sndcdn.com",

          "domain:chatgpt.com",
          "domain:openai.com",
          "domain:oaistatic.com",
          "domain:oaiusercontent.com",
          "domain:auth.openai.com",
          "domain:auth0.openai.com",

          "domain:whatsapp.com",
          "domain:whatsapp.net",
          "domain:whatsapp-cdn.net",
          "domain:static.whatsapp.net",
          "domain:cdn.whatsapp.net",

          "domain:youtube.com",
          "domain:youtu.be",
          "domain:googlevideo.com",
          "domain:ytimg.com",
          "domain:ggpht.com",
          "domain:gvt1.com",
          "domain:gvt2.com",
          "domain:gvt3.com",
          "domain:gvt6.com",
          "domain:youtubei.googleapis.com",
          "domain:youtube.googleapis.com",
          "domain:gstatic.com",
          "domain:googleapis.com",
          "domain:googleusercontent.com",
          "domain:accounts.google.com",
          "domain:clients3.google.com",
          "domain:clients4.google.com",
          "domain:google.com",
          "domain:android.com",
          "domain:connectivitycheck.android.com",
          "domain:mtalk.google.com",

          "domain:x.com",
          "domain:twitter.com",
          "domain:t.co",
          "domain:twimg.com",

          "domain:facebook.com",
          "domain:fb.com",
          "domain:facebook.net",
          "domain:fbcdn.net",
          "domain:fbsbx.com",

          "domain:instagram.com",
          "domain:cdninstagram.com",

          "domain:intel.com",

          "domain:netflix.com",
          "domain:netflix.net",
          "domain:fast.com",
          "domain:nflxext.com",
          "domain:nflximg.com",
          "domain:nflximg.net",
          "domain:nflxsearch.net",
          "domain:nflxso.net",
          "domain:nflxvideo.net"
        ],
        "outboundTag": "proxy"
      }
    ]
  }
}

Указание проксируемых доменов
Здесь стоит обратить внимания на директиву "domain", в нее записывается список доменных имен, которые будут проксироваться.

Зачем здесь tun inbound и что он делает
Tun инбаунд заставляет xray создать виртуальный TUN-интерфейс xray0. Это обычный линуксовый L3 интерфейс, ядро считает его сетевой картой, куда можно маршрутизировать пакеты. Дальше политика маршрутизации на роутере направит трафик клиентов в этот интерфейс. Таким образом xray становится своего рода прокладкой между LAN и WAN.

Зачем нужен Sniffing для раздельного туннелирования
Раздельное туннелирование по доменным именам невозможно на уровне L3/L4 (маршрутизация и iptables видят ip/port, но не видят домен). Поэтому мы заставляем xray снифать SNI в Client Hello, host- заголовок в HTTP и домены в QUIC (udp/443), когда это возможно.

Direct & proxy outbounds
У нас 2 выхода: "direct" через провайдера и "proxy" до vps. Важная деталь - direct стоит первым не просто так. Это приоретизация, которая означает, что если правило роутинга по каким-либо причинам не сработает (не распознан домен, нет в списке и т.д.), трафик уйдет через провайдера, а не случайно польется в VPN целиком.

Geoip Google
Наличие правила "ip": ["geoip:google"] означает, что любой трафик к Google IP-пулам уходит в туннель даже если домен не снифается. Это может быть полезно для Google-приложений, например, на ТВ, когда мы не знаем, к каким апстримам обращается приложение под капотом.
Кроме того, он выполняет роль страховки от случаев, когда домен не получается определить и вместо срабатывания правила приоритета маршрутизации через провайдера, мы гарантированно отправляем Google IP-пулы в VPN. Да, это расширяет объем туннелированного трафика, но делает поведение google-сервисов менее плавающим.

Почему в списке лишние домены
Как упомянул ранее, для браузера обычно хватает доменного имени, но приложения ходят на api, в картинки и еще много куда. Если не включать CDN, то приложение может стартовать, но не отдавать корректно контент.

Как трафик попадает в xray
Конфиг регулирует только то, как xray делит трафик. Попадание трафика в xray регулируется рядом PBR, об этом более подробно будет описано ниже по тексту.

Первый запуск и проверка интерфейса:

mkdir -p /opt/var/log
killall xray 2>/dev/null
/opt/bin/xray run -c /opt/etc/xray/config.json >/opt/var/log/xray.log 2>&1 &
sleep 1
/opt/sbin/ip link show xray0
tail -n 80 /opt/var/log/xray.log

В случае успеха увидим что-то такое:

Pasted-image-20260309013549.png
Pasted-image-20260309013549.png

Проверяем маршрут по умолчанию:

/opt/sbin/ip -4 route show default
/opt/sbin/ip -4 route show proto kernel scope link
Pasted-image-20260309014004.png
Pasted-image-20260309014004.png

Проверяем, что xray0 в up:

/opt/sbin/ip link show xray0
/opt/sbin/ip -4 route show default

Если отдает какие-либо ошибки, например Device "xray0" does not exist, выполняем скрипт перезапуска:

killall xray 2>/dev/null
mkdir -p /opt/var/log
/opt/bin/xray run -c /opt/etc/xray/config.json >/opt/var/log/xray.log 2>&1 &
sleep 1
/opt/sbin/ip link show xray0 || true
tail -n 120 /opt/var/log/xray.log
Pasted-image-20260309181638.png
Pasted-image-20260309181638.png

Настройка маршрутизации

Дальше начинается самое интересное - как заставить клиентский трафик из LAN проходить через xray0, но при этом не сломать управление роутером, не умереть из-за NAT и фаервола и не чинить руками после каждого рестарта xray.

Почему наивная маршрутизация ломает интернет
Самая частая ошибка в гайдах - это попытка маршрутизировать по source-подсети:

ip rule add from 192.168.1.0/24 table 100
ip route add default dev xray0 table 100

Это выглядит логично, но на Keenetic ломает все очень быстро. Причина банальна - сам роутер имеет адрес 192.168.1.1 и тоже попадает в 192.168.1.0/24. В итоге DNS/NTP/системные запросы роутера тоже начинают уходить в туннель. Появляется плавающая сеть, веб-морда зависает, SSH отваливается. Правильным подходом будет маркировать (fwmark) только форвардящийся трафик из LAN, а не трафик самого роутера.

Архитектура решения

Мы строим цепочку:

  1. iptables mangle PREROUTING - помечаем трафик, пришедший из LAN (br0) и идущий не в локальную сеть, меткой 0x1;

  2. ip rule - весь трафик с меткой 0x1 отправляем в таблицу маршрутизации 100;

  3. table 100 - default dev xray0, плюс анти-петля до VPS, чтоб не зациклиться;

  4. Xray получает весь поток в TUN и внутри делит его по следующему принципу: домены из списка в xray, всё остальное в direct. Эта схема позволяет доменный сплит делать на L7 через sniff SNI/host, а L3/L4 оставить системе.

Policy routing: обязательные три правила

Далее пойдёт “эталонный” набор правил. Каждое из них решает конкретную проблему.

Таблица маршрутизации 100 + ip rule по fwmark
Сначала создаем table 100 и правило, которое будет направлять маркированный трафик в эту таблицу:

VPS_IP="ваш ip"
WAN_IF=$(/opt/sbin/ip -4 route show default | awk '{print $5; exit}')
WAN_GW=$(/opt/sbin/ip -4 route show default | awk '{print $3; exit}')
/opt/sbin/ip rule del priority 10000 2>/dev/null || true
/opt/sbin/ip route flush table 100 2>/dev/null || true
/opt/sbin/ip route replace default dev xray0 table 100
/opt/sbin/ip route replace ${VPS_IP}/32 via ${WAN_GW} dev ${WAN_IF} table 100
/opt/sbin/ip rule add fwmark 0x1 table 100 priority 10000

Почему нужен маршрут до VPS в table 100
Это анти-петля, спасающая от того, что трафик до самого VPS-сервера может попытаться уйти в туннель, и получится самопожирающийся цикл.

Проверка:

/opt/sbin/ip route show table 100
/opt/sbin/ip rule | tail -n 20

NAT-исключение: POSTROUTING RETURN -o xray0

Это тот нюанс, из-за которого многие решения работают “то да, то нет”, либо вообще не работают. На кинетике по умолчанию есть SNAT/MASQUERADE для LAN. Если не исключить выход в xray0, трафик внутри туннеля может получить не тот src, ответы не вернутся клиенту, и будет ощущение, что всё умерло.

Фиксится так:

/opt/sbin/iptables -t nat -D POSTROUTING -o xray0 -j RETURN 2>/dev/null || true
/opt/sbin/iptables -t nat -I POSTROUTING 1 -o xray0 -j RETURN

Данное правило должно быть первым в POSTROUTING. Проверка:

/opt/sbin/iptables -t nat -L POSTROUTING -n -v --line-numbers | head -n 10

Затем нужно разрешить форвардинг между br0 и xray0. KeeneticOS использует достаточно строгие ACL-цепочки NDM, трафик LAN - tun может быть просто отфильтрован. Поэтому добавляем явные допуски:

/opt/sbin/iptables -D FORWARD -i br0  -o xray0 -j ACCEPT 2>/dev/null || true
/opt/sbin/iptables -D FORWARD -i xray0 -o br0  -j ACCEPT 2>/dev/null || true
/opt/sbin/iptables -I FORWARD 1 -i br0  -o xray0 -j ACCEPT
/opt/sbin/iptables -I FORWARD 1 -i xray0 -o br0  -j ACCEPT
/opt/sbin/iptables -L FORWARD -n -v --line-numbers | head -n 20

Маркировка трафика: тест на одном устройстве

Самый безопасный способ не уронить всю сеть - включить маркировку только для одного клиента. В случае каких-либо ошибок отвалится только одно устройство, а не всё, что подключено к вайфаю. Узнаем текущий LAN айпишник, в моем случае это 192.168.1.59, и запускаем скрипт:

LAN_NET="192.168.1.0/24"
CLIENT_IP="192.168.1.59"
/opt/sbin/iptables -t mangle -D PREROUTING -i br0 -s ${CLIENT_IP} ! -d ${LAN_NET} -j MARK --set-mark 0x1 2>/dev/null || true
/opt/sbin/iptables -t mangle -I PREROUTING 1 -i br0 -s ${CLIENT_IP} ! -d ${LAN_NET} -j MARK --set-mark 0x1
/opt/sbin/iptables -t mangle -L PREROUTING -n -v --line-numbers | head -n 15

Если все в порядке, то при хождении по сайтам счетчик pkts/bytes у MARK будет расти. Если не все в порядке, рекомендую пройти чеклист для локализации проблемы:

Проверка, что xray здравствует и tun есть:

pidof xray
/opt/sbin/ip link show xray0

Проверка правил:

/opt/sbin/ip rule | tail -n 30
/opt/sbin/ip route show table 100

Проверка MARK и NAT:

/opt/sbin/iptables -t mangle -L PREROUTING -n -v --line-numbers | head -n 10
/opt/sbin/iptables -t nat -L POSTROUTING -n -v --line-numbers | head -n 10

Если все проверки успешны (Xray жив, xray0 UP, table 100 содержит default dev xray0, MARK и RETURN присутствуют и их счетчики растут), значит транспортная часть схемы исправна и трафик 100% попадает в xray. В этом случае косяк нужно искать либо в самом xray конфиге, либо в доступности VPS-сервера, либо в особенностях конкретного приложения. Если хотя бы один пункт не выполняется - проблема на найденном слое и дальнейшие действия зависят от того, на каком этапе произошел факап.

Раскатка на всю сеть

Дальше - проще. Если все протестировано и один клиент работает, убираем -s CLIENT_IP и маркируем весь LAN:

LAN_NET="192.168.1.0/24"
CLIENT_IP="192.168.1.59"
/opt/sbin/iptables -t mangle -D PREROUTING -i br0 -s ${CLIENT_IP} ! -d ${LAN_NET} -j MARK --set-mark 0x1 2>/dev/null || true
/opt/sbin/iptables -t mangle -D PREROUTING -i br0 ! -d ${LAN_NET} -j MARK --set-mark 0x1 2>/dev/null || true
/opt/sbin/iptables -t mangle -I PREROUTING 1 -i br0 ! -d ${LAN_NET} -j MARK --set-mark 0x1

Поздравляю, на этом этапе раздельное туннелирование применяется ко всем устройствам в сети!

Почему после перезапуска Xray может сломаться (и как это убрать)

Вы могли заметить странную вещь: все работает... но до первого рестарта xray, после которого интересующие нас ресурсы перестают открываться. Причина не в xray и не в доменах. Причина в одной неочевидной детали работы линукса: xray при рестарте пересоздает интерфейс xray0, маршруты типа default dev xray0 в table 100 могут исчезать (интерфейс то удаляется, то создается заново), а в итоге ip rule и MARK остаются, но table 100 уже не знает куда отправлять трафик.

Именно поэтому мы делаем идемпотентный apply-скрипт + init.d сервис!

cat > /opt/bin/xray-pbr-apply.sh <<'SH'
#!/opt/bin/sh
set -eu

LAN_NET="192.168.1.0/24"
VPS_IP="ваш ip"
MARK="0x1"
TABLE="100"
PRIO="10000"

IP="/opt/sbin/ip"
IPT="/opt/sbin/iptables"

WAN_IF="$($IP -4 route show default | awk '{print $5; exit}')"
WAN_GW="$($IP -4 route show default | awk '{print $3; exit}')"

i=0
while ! $IP link show xray0 >/dev/null 2>&1; do
  i=$((i+1))
  [ "$i" -ge 20 ] && exit 1
  sleep 1
done

if ! $IP rule | grep -q "fwmark $MARK.*lookup $TABLE"; then
  $IP rule add fwmark $MARK table $TABLE priority $PRIO
fi

if ! $IP route show table $TABLE | grep -q "^default dev xray0"; then
  $IP route replace default dev xray0 table $TABLE
fi

if ! $IP route show table $TABLE | grep -q "^$VPS_IP/32"; then
  $IP route replace $VPS_IP/32 via "$WAN_GW" dev "$WAN_IF" table $TABLE
fi

if ! $IPT -t nat -C POSTROUTING -o xray0 -j RETURN 2>/dev/null; then
  $IPT -t nat -I POSTROUTING 1 -o xray0 -j RETURN
fi

if ! $IPT -C FORWARD -i br0 -o xray0 -j ACCEPT 2>/dev/null; then
  $IPT -I FORWARD 1 -i br0 -o xray0 -j ACCEPT
fi
if ! $IPT -C FORWARD -i xray0 -o br0 -j ACCEPT 2>/dev/null; then
  $IPT -I FORWARD 1 -i xray0 -o br0 -j ACCEPT
fi

if ! $IPT -t mangle -C PREROUTING -i br0 ! -d "$LAN_NET" -j MARK --set-mark $MARK 2>/dev/null; then
  $IPT -t mangle -I PREROUTING 1 -i br0 ! -d "$LAN_NET" -j MARK --set-mark $MARK
fi

exit 0
SH

chmod +x /opt/bin/xray-pbr-apply.sh

Скрипт проверяет наличие xray0, default dev xray0 в table 100, маршрут до VPS, ip rule fwmark и MARK/NAT/FORWARD правила. Если что-то не так - восстанавливает.

Затем создадим guard, который будет поднимать xray, если он умер, и запускает apply-скрипт, чтобы вернуть маршруты, если что-то сбросилось

cat > /opt/etc/init.d/S99xray <<'SH'
#!/opt/bin/sh

XRAY="/opt/bin/xray"
CFG="/opt/etc/xray/config.json"
LOG="/opt/var/log/xray.log"
PIDF="/opt/var/run/xray.pid"
GUARDPID="/opt/var/run/xray-guard.pid"

start_xray() {
  mkdir -p /opt/var/log /opt/var/run
  if [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF")" 2>/dev/null; then
    return 0
  fi
  : > "$LOG"
  "$XRAY" run -c "$CFG" >>"$LOG" 2>&1 &
  echo $! > "$PIDF"
}

stop_xray() {
  if [ -f "$PIDF" ]; then
    kill "$(cat "$PIDF")" 2>/dev/null || true
    rm -f "$PIDF"
  else
    killall xray 2>/dev/null || true
  fi
}

start_guard() {
  if [ -f "$GUARDPID" ] && kill -0 "$(cat "$GUARDPID")" 2>/dev/null; then
    return 0
  fi
  (
    while true; do
      if ! pidof xray >/dev/null 2>&1; then
        start_xray
      fi
      /opt/bin/xray-pbr-apply.sh >/dev/null 2>&1 || true
      sleep 15
    done
  ) &
  echo $! > "$GUARDPID"
}

stop_guard() {
  if [ -f "$GUARDPID" ]; then
    kill "$(cat "$GUARDPID")" 2>/dev/null || true
    rm -f "$GUARDPID"
  fi
}

case "${1:-}" in
  start)
    start_xray
    /opt/bin/xray-pbr-apply.sh || true
    start_guard
    ;;
  stop)
    stop_guard
    stop_xray
    ;;
  restart)
    stop_guard
    stop_xray
    sleep 1
    start_xray
    /opt/bin/xray-pbr-apply.sh || true
    start_guard
    ;;
  *)
    echo "Usage: $0 {start|stop|restart}"
    exit 1
    ;;
esac
SH

chmod +x /opt/etc/init.d/S99xray
/opt/etc/init.d/S99xray start

Проверить работу гварда можно сымитировав проблему, например удалить default route из table 100 и посмотреть, восстановится ли он сам через 15-20 секунд.

/opt/sbin/ip route del default table 100
sleep 20
/opt/sbin/ip route show table 100

Итоги и отдельные нюансы, которые стоит упомянуть

Если вдруг у вас в ходу IPv6, часть устройств может уйти по нему мимо VPS, потому что мы строили PBR под IPv4. Самый простое решение это отключить IPv6 в принципе на всех устройствах, т.к. его поддержка - это отдельный гигантский пласт работ.

Единственное, чего не удалось победить - это whatsapp. Судя по всему, он использует еще ряд соединений, которые либо идут на голые ip без sni, либо на какие-то неочевидные домены и порты, и текущим доменным сплитом это надежно не закрыть. В остальном получилась вполне себе надежная система.