Як виключити підмережу з тунелю VPN на Android
VpnService не може виключити маршрут, тож ви додаєте доповнення: вісім згенерованих префіксів для /8, плюс перевірки IPv6, DNS і пошуку, які ловлять решту.
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, а не інструмент проєктування.