Tunnel divisé sous Android VpnService : ce que vous pouvez réellement router
VpnService ne transmet que les paquets que le builder place dans son descripteur. Vérifiez préfixes, authentification du proxy, DNS et listes d'applications avant d'accuser le tunnel divisé.
Le tunnel divisé dans une application VpnService casse en quelques endroits précis, et la réparation diffère pour chacun. Le client n'affiche qu'une destination qui échoue, donc le premier travail consiste à déterminer quelle frontière vous heurtez : les routes reçues par le builder, le proxy que vous ne pouvez pas authentifier, le résolveur que vous avez seulement annoncé ou la liste d'applications que la plateforme applique par utilisateur. C'est exactement pour cela que je garde une vérification en quatre étapes. Elle s'exécute dans l'ordre ci-dessous et s'arrête au premier échec.
Confirmez ce que le builder a remis à la plateforme
VpnService.Builder est une liste de déclarations. establish() renvoie un ParcelFileDescriptor pour une interface virtuelle, et la plateforme n'envoie dans ce descripteur que le trafic correspondant aux préfixes d'addAddress et d'addRoute ; addDnsServer indique aux clients quel résolveur interroger. Une destination hors de tous les préfixes ajoutés n'atteint jamais votre application, quoi que promette l'interface. Ce seul fait explique la plupart des signalements de tunnel divisé cassé avant toute modification de code.
Journalisez les listes d'adresses, de routes et de DNS assemblées par votre builder, puis comparez-les aux destinations qui échouent. Un /32 manquant pour un hôte d'administration est bien plus fréquent qu'un tunnel cassé. Si la liste est correcte, continuez ; si elle est fausse, le reste de la liste de contrôle est un effort perdu.
excludeRoute n'existe qu'à partir de l'API 33
L'exclusion par destination est la demande qui finit le plus souvent en fil de support. builder.excludeRoute(IpPrefix) est arrivé avec l'API 33 ; avant, la seule approximation prise en charge consiste à ajouter des routes pour tout sauf le préfixe exclu, ce qui impose de calculer vous-même les préfixes complémentaires. Les deux appels ne sont pas des images miroir : l'un soustrait de l'ensemble de routes de l'interface, l'autre construit l'ensemble à partir de zéro, et un appareil plus ancien lèvera ou ignorera silencieusement un appel qu'il ne connaît pas. Branchez sur Build.VERSION.SDK_INT et testez les deux chemins sur du matériel réel.
Quand vous calculez des compléments, surveillez l'alignement des préfixes. Une exclusion /24 dans une route /16 exige les préfixes couvrants qui décrivent réellement le reste, pas un /8 mal formé. Journalisez la liste finale et vérifiez que l'hôte exclu se résout toujours par un chemin hors du tunnel.
Les identifiants du proxy ne sont pas exprimables
setHttpProxy(ProxyInfo) pointe l'interface vers un proxy HTTP ou SOCKS, et une URL PAC peut choisir entre eux par destination. Ni ProxyInfo ni le format PAC ne transportent un nom d'utilisateur et un mot de passe, donc un proxy authentifié reste hors de l'API de la plateforme. Les applications qui en ont besoin intègrent généralement une pile en espace utilisateur, tun2socks ou un moteur similaire, et répondent elles-mêmes au défi 407.
Ce choix a des conséquences. Une pile en espace utilisateur ne voit que le trafic que vous lui routez, elle doit donc vivre dans la même frontière de descripteur et hérite de la même exigence de protect() pour son propre socket de liaison montante. Omettez protect() et le tunnel capture son propre transport, ce qui se manifeste par une poignée de main qui bloque après le premier paquet. Testez contre un proxy qui exige des identifiants, car un proxy ouvert passe par le chemin de la plateforme et ne prouve rien.
Les listes par application et le DNS sont aussi des déclarations
Builder possède addAllowedApplication et addDisallowedApplication, et ils s'excluent mutuellement : appeler les deux lève une exception. Dans tous les cas la liste est appliquée par utilisateur à la configuration de l'interface, donc une application du profil professionnel est régie par le tunnel du profil professionnel et non par le vôtre. Ce qu'aucune API ne donne, c'est la maîtrise de la décision d'une autre application : vous ne pouvez pas pousser un paquet dans votre tunnel depuis l'extérieur, ni fermer ou reconfigurer un VPN déjà actif.
Le DNS suit le même schéma. addDnsServer annonce une adresse de résolveur ; il ne permet pas à votre application de répondre aux requêtes. Si vous voulez résoudre les noms vous-même, lancez un résolveur et routez son adresse dans le tunnel. Les applications qui utilisent DoT ou DoH vers un nom d'hôte fixe peuvent contourner l'adresse annoncée, alors traitez la résolution de noms comme un meilleur effort, sauf si vous interceptez aussi le chemin de la requête.
Manifeste, always-on et le test en quatre étapes
L'éligibilité se déclare dans le manifeste : BIND_VPN_SERVICE, le filtre d'intent android.net.VpnService et un service de premier plan si le tunnel doit survivre en arrière-plan. Always-on VPN exige en plus un onRevoke propre, car le système l'appelle lorsque l'utilisateur change les réglages ou qu'une autre application prend la main.
Exécutez les contrôles dans l'ordre et arrêtez-vous au premier échec. D'abord, exportez les listes d'adresses, de routes et de DNS passées au builder. Ensuite, confirmez qu'establish() a renvoyé un descripteur et que le socket de transport est protégé. Troisièmement, demandez une destination dans un préfixe inclus et une dans un préfixe exclu. Quatrièmement, vérifiez quel résolveur a réellement répondu en interrogeant un nom d'hôte que seul votre résolveur peut résoudre.
Une poignée de main terminée ne prouve aucune des étapes suivantes. Chaque contrôle isole une couche, donc un succès à la deuxième étape ne dit rien de la quatrième.
La référence VpnService.Builder énumère toute la surface du builder. Tout ce dont vous avez besoin au-delà relève d'une autre API, d'une autre application ou de votre propre pile en espace utilisateur, et c'est une décision de conception, pas un bug de tunnel divisé.