У VpnService в Android нет маршрута исключения, и никакое чтение API его не создаст. Builder.addRoute() всегда только добавляет адресаты в туннель, поэтому сохранение сегмента LAN локальным означает, что этот сегмент вообще не попадает в список маршрутов. Вместо этого вы добавляете дополнение сегмента, выраженное обычными префиксами. Работа арифметическая, и она срывается в нескольких предсказуемых местах.

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

Начните с того, что заявляет туннель

Передача 0.0.0.0/0 в addRoute() говорит Android, что каждый адресат IPv4 принадлежит туннелю. Поэтому столько клиентов выглядят неспособными к раздельному туннелированию: они заявляют всё адресное пространство, а затем пытаются из него вычесть. Ничто в конструкторе не вычитает. Более конкретный маршрут для сети, которую вы хотели оставить локальной, даёт обратное ожидаемому, потому что побеждает самый длинный совпадающий префикс и эта сеть оказывается внутри туннеля.

Список маршрутов — это вся политика. За ним нет вето для отдельного адресата, и справочник Builder не перечисляет метода, который бы его давал.

Сгенерируйте дополнение из арифметики префиксов

Дополнение исключённого префикса — это множество его братьев на пути к корню дерева адресов. Для каждой длины от 1 до исключённой возьмите блок этой длины, содержащий ваш префикс, переверните бит непосредственно под префиксом и выведите результат. Звучит абстрактно, пока не запустишь.

Исключение 10.0.0.0/8 даёт восемь маршрутов: 128.0.0.0/1, 64.0.0.0/2, 32.0.0.0/3, 16.0.0.0/4, 0.0.0.0/5, 12.0.0.0/6, 8.0.0.0/7, 11.0.0.0/8. Ни один из них не содержит 10.0.0.0/8, а вместе они покрывают всё остальное.

Восемь строк генерируют этот список для любого префикса:

import ipaddress
def complement(addr, length):
    base = int(ipaddress.IPv4Address(addr))
    out = []
    for k in range(1, length + 1):
        block = base & (0xFFFFFFFF << (32 - k))
        sibling = block ^ (1 << (32 - k))
        out.append(f"{ipaddress.IPv4Address(sibling)}/{k}")
    return out
print(complement("10.0.0.0", 8))

Число маршрутов равно длине префикса, и это то число, за которым нужно следить. Одно исключение /21 стоит 21 маршрут и остаётся читаемым. Десять исключений могут стоить несколько сотен, что обычно означает, что менять нужно проект сети, а не генератор.

Проверьте семейство адресов и охват туннеля

Маршруты IPv4 ничего не делают для IPv6. Если туннель заявляет адрес IPv6, локальному префиксу IPv6 нужно собственное дополнение, сгенерированное той же арифметикой по 128 битам. Частая причина, по которой исключение работает на бумаге, а локальное устройство остаётся недоступным, такова: до хоста добрались по IPv6, а вырезан был только IPv4. Проверьте оба семейства, прежде чем заключить, что арифметика неверна.

Охват — вторая половина проверки. Android отслеживает маршрутизацию по сети и по приложению, поэтому один и тот же префикс может идти через туннель для одного приложения и напрямую для другого в зависимости от того, как объявлен VPN.

Разместите правила DNS и по приложениям там, где им место

Исключения маршрутов не меняют резолвер. Локальное имя хоста всё ещё может разрешаться DNS-сервером туннеля, пока его адрес маршрутизируется локально, и несоответствие проявляется как имя, которое разрешается в нечто недостижимое для LAN. Задайте политику резолвера осознанно, с доменом поиска или правилом разделённого DNS. Не предполагайте, что маршруты решили это за вас.

addDisallowedApplication() держит одно приложение вне туннеля; он не держит адресат снаружи для приложений, которые остаются внутри. protect() позволяет открытому вами сокету обойти туннель, и так туннель избегает перехвата собственного транспорта. Ни то, ни другое не заменяет дополнение.

Проверяйте поиском, а не собственным списком

Когда туннель поднят, спросите устройство, куда адрес на самом деле идёт, вместо того чтобы перечитывать построенный вами список. На сборке с iproute2 adb shell ip route get 10.1.2.3 называет интерфейс, выбранный ядром. Выполните ту же команду для публичного адреса и убедитесь, что она называет туннель.

Ещё две проверки замыкают цикл. Сначала разрешите локальное имя, затем выполните adb shell ip route get по разрешённому адресу и потребуйте интерфейс, который не является туннелем, потому что успешный adb shell ping -c 1 host.lan доказывает достижимость, а не путь. Повторите тест семейства адресов по IPv6, если туннель заявляет его. Тестируйте на том уровне API, который поставляете, потому что то, как сети VPN предстают перед таблицей маршрутизации, менялось между выпусками. Список маршрутов, который выглядит правильным на бумаге, и поиск, выбирающий неверный интерфейс, — разные ошибки, и только вторая доходит до пользователей.

Если список исключений продолжает расти, перестаньте генерировать маршруты и перенумеруйте удалённую сеть или вместо этого сузьте туннель. Генератор — обход отсутствующего API, а не инструмент проектирования.

Похожие статьи