El túnel dividido en una aplicación VpnService se rompe en unos pocos puntos concretos, y la reparación es distinta en cada uno. El cliente solo muestra un destino que falla, así que la primera tarea es averiguar contra qué límite chocas: las rutas que recibió el builder, el proxy que no puedes autenticar, el resolver que solo anunciaste o la lista de aplicaciones que la plataforma aplica por usuario. Por eso mantengo una comprobación de cuatro pasos. Se ejecuta en el orden de abajo y se detiene en el primer fallo.

Confirma qué entregó el builder a la plataforma

VpnService.Builder es una lista de declaraciones. establish() devuelve un ParcelFileDescriptor para una interfaz virtual, y la plataforma solo envía a ese descriptor el tráfico que coincide con los prefijos de addAddress y addRoute; addDnsServer dice a los clientes qué resolver deben consultar. Un destino fuera de todos los prefijos que añadiste nunca llega a tu aplicación, diga lo que diga la interfaz. Ese único hecho explica la mayoría de los informes de túnel dividido roto antes de tocar código.

Registra las listas de direcciones, rutas y DNS que montó tu builder y compáralas con los destinos que fallan. Un /32 ausente para un host de gestión es mucho más habitual que un túnel roto. Si la lista es correcta, sigue adelante; si no lo es, el resto de la lista de comprobación es esfuerzo perdido.

excludeRoute solo existe desde la API 33

La exclusión por destino es la petición que más veces acaba en un hilo de soporte. builder.excludeRoute(IpPrefix) llegó en la API 33; antes, la única aproximación admitida es añadir rutas para todo menos el prefijo excluido, lo que obliga a calcular tú mismo los prefijos complementarios. Las dos llamadas no son imágenes especulares: una resta del conjunto de rutas de la interfaz, la otra construye el conjunto desde cero, y un dispositivo antiguo lanzará o ignorará en silencio una llamada que no reconoce. Ramifica según Build.VERSION.SDK_INT y prueba ambos caminos en hardware real.

Cuando calcules complementos, vigila la alineación de prefijos. Una exclusión /24 dentro de una ruta /16 necesita los prefijos de cobertura que describen de verdad el resto, no un /8 mal formado. Registra la lista final y comprueba que el host excluido todavía se resuelve por una ruta fuera del túnel.

Las credenciales del proxy no se pueden expresar

setHttpProxy(ProxyInfo) apunta la interfaz a un proxy HTTP o SOCKS, y una URL PAC puede elegir entre ambos por destino. Ni ProxyInfo ni el formato PAC llevan usuario y contraseña, así que un proxy autenticado queda fuera de la API de la plataforma. Las aplicaciones que lo necesitan suelen incrustar una pila de espacio de usuario, tun2socks o un motor similar, y responden ellas mismas al reto 407.

Esa decisión tiene consecuencias. Una pila de espacio de usuario solo ve el tráfico que enrutas hacia ella, así que debe vivir dentro del mismo límite del descriptor, y hereda el mismo requisito de protect() para su propio socket de subida. Si te saltas protect(), el túnel captura su propio transporte, lo que aparece como un handshake que se atasca tras el primer paquete. Prueba contra un proxy que exija credenciales, porque un proxy abierto pasa por el camino de la plataforma y no demuestra nada.

Las listas por aplicación y el DNS también son declaraciones

Builder tiene addAllowedApplication y addDisallowedApplication, y son mutuamente excluyentes: llamar a ambos lanza una excepción. En cualquier caso la lista se aplica por usuario al configurar la interfaz, así que una aplicación del perfil de trabajo se rige por el túnel del perfil de trabajo y no por el tuyo. Lo que ninguna API te da es control sobre la decisión de otra aplicación: no puedes forzar un paquete hacia tu túnel desde fuera ni cerrar o reconfigurar una VPN ya activa.

El DNS sigue el mismo patrón. addDnsServer anuncia una dirección de resolver; no permite que tu aplicación responda consultas. Si quieres resolver nombres tú mismo, ejecuta un resolver y enruta su dirección al túnel. Las aplicaciones que usan DoT o DoH hacia un nombre de host fijo pueden saltarse la dirección que anunciaste, así que trata la resolución de nombres como mejor esfuerzo salvo que también interceptes la ruta de la consulta.

Manifiesto, always-on y la prueba de cuatro pasos

La elegibilidad se declara en el manifiesto: BIND_VPN_SERVICE, el filtro de intents android.net.VpnService y un servicio en primer plano si el túnel debe sobrevivir en segundo plano. Always-on VPN además exige un onRevoke limpio, porque el sistema lo llama cuando el usuario cambia ajustes o otra aplicación toma el control.

Ejecuta las comprobaciones en orden y detente en el primer fallo. Primero, vuelca las listas de direcciones, rutas y DNS que recibió el builder. Segundo, confirma que establish() devolvió un descriptor y que el socket de transporte está protegido. Tercero, pide un destino dentro de un prefijo incluido y otro dentro de un prefijo excluido. Cuarto, comprueba qué resolver respondió de verdad consultando un nombre de host que solo tu resolver puede resolver.

Un handshake completado no demuestra ninguno de los pasos posteriores. Cada comprobación aísla una capa, así que un éxito en el segundo paso no dice nada sobre el cuarto.

La referencia de VpnService.Builder enumera toda la superficie del builder. Todo lo que necesites más allá es otra API, otra aplicación o tu propia pila de espacio de usuario, y eso es una decisión de diseño, no un fallo del túnel dividido.

Artículos relacionados