Por qué tu túnel Android VpnService deja de enrutar el tráfico
Cuatro creencias sobre VpnService que se rompen en producción: un túnel a la vez, alcance de las rutas, listas por aplicación y orden de apagado, con una comprobación para cada una.
Cuatro suposiciones sobre Android VpnService me costaron una vez buena parte de una semana, y ninguna de ellas trataba del protocolo de red. La aplicación decía conectado, el icono de la llave estaba en la barra de estado, y el tráfico seguía saliendo por la ruta normal. Yo seguía suponiendo que el túnel era mío hasta que lo paraba, que las rutas que añadía eran las rutas que obtenía, y que llamar a stopSelf se llevaría la interfaz con él. El servicio se comportaba exactamente como está documentado. La parte rota era mi modelo mental.
Mito 1: el túnel sigue en pie hasta que lo paras
Android mantiene una interfaz VPN a la vez. Establece una segunda y la plataforma no la pone en cola: desactiva la tuya y llama a onRevoke() en tu servicio. Si tu código solo desmonta al pulsar un botón, una toma de control deja atrás un descriptor y un hilo lector, y el siguiente arranque hereda ambos.
La referencia de VpnService dice explícitamente que prepare() devuelve null cuando la aplicación ya tiene consentimiento y un Intent cuando el usuario todavía tiene que aprobarlo. Ese consentimiento solo llega si entregas la intención mediante startActivityForResult(). Sáltate la entrega y establish() devuelve null, lo que se lee como un fallo silencioso y no como un problema de permisos.
Always-on VPN añade un dueño que no controlas. El sistema puede devolver el túnel tras un arranque o tras una caída, y se adelanta a una aplicación VPN que el usuario inició a mano. Construye onRevoke() para que cierre los mismos recursos que tu apagado normal, y trátalo como una vía de parada principal y no como un caso raro. Los usuarios cambian de aplicación VPN; tu código tiene que sobrevivir a eso.
Mito 2: addRoute captura exactamente lo que enumeraste
Las rutas deciden qué paquetes llegan a tu descriptor, y un error de alcance produce el caso exasperante en el que parte del tráfico va por el túnel y parte no. addRoute("0.0.0.0", 0) toma todo el IPv4, lo que significa que cada paquete llega ahora a ti y hay que reenviarlo o descartarlo. Un /32 para un solo host no toma casi nada, y todo lo demás se comporta como si tu VPN nunca se hubiera instalado.
Las familias se cuentan por separado. Una ruta solo IPv4 deja IPv6 en la red subyacente, así que un host de doble pila todavía puede resolver y conectar fuera del túnel mientras parece perfectamente conectado dentro.
El DNS sigue la misma lógica. Los servidores añadidos con addDnsServer() van a las aplicaciones que usan el resolutor del túnel, mientras que una aplicación con su propio punto final DNS cifrado o con Private DNS fijado a un nombre de host los ignora por completo. Cuando los nombres fallan para una aplicación y funcionan para otra, revisa la ruta del resolutor antes de tocar una sola ruta.
Mito 3: las reglas por aplicación son un interruptor que puedes girar en cualquier sentido
addDisallowedApplication() y addAllowedApplication() son dos modos, no dos filtros. El constructor acepta un modelo por túnel, así que una aplicación que quiere todo excepto tres paquetes usa la lista de prohibidos y deja vacía la de permitidos. Los dos son mutuamente excluyentes, y la gente lo descubre en tiempo de ejecución después de que la pantalla de ajustes ya haya ofrecido ambos conmutadores.
La visibilidad de paquetes también muerde. En Android 11 y posteriores solo puedes nombrar paquetes que puedas ver, así que una lista de exclusiones construida a partir de una consulta que tu aplicación no puede ejecutar perderá entradas en silencio. Compara la lista con lo que el gestor de paquetes devuelve realmente antes de confiar en ella.
Los perfiles de trabajo vuelven a dividir la audiencia. Un túnel iniciado dentro del perfil cubre ese perfil y sus aplicaciones, mientras que el lado personal conserva su propia conectividad. Es una decisión de alcance tomada una vez, y pertenece a la interfaz, no a un ticket de soporte.
Mito 4: stopSelf termina el túnel
No lo termina. La interfaz vive en el descriptor que devuelve establish(), y el objeto del servicio puede morir mientras ese descriptor sigue abierto. Cierra primero el ParcelFileDescriptor, suelta la referencia, une o interrumpe los hilos de lectura y escritura, y luego llama a stopForeground(true) y stopSelf() en ese orden.
Un descriptor olvidado es la razón habitual por la que un servicio parece sobrevivir a su propia petición de parada, y explica una ruta obsoleta que vive más que la aplicación que la añadió.
El error inverso es igual de común: cerrar el descriptor mientras un hilo lector está a mitad de lectura, y luego dejar que ese hilo maneje una excepción para la que nadie escribió nada. Mantén la secuencia de apagado en un solo lugar y haz que tanto la acción del usuario como onRevoke() llamen a ella.
Qué comprobar cuando el túnel parece muerto
Protege los sockets de larga vida con protect() antes de que el túnel suba. Sin ello, el transporte del propio túnel se enruta de vuelta al túnel y la conexión se atasca de una forma que parece exactamente una falla del servidor. Prueba contra una dirección que solo exista detrás de la red remota, no contra un sitio público que responda desde la caché o desde la ruta subyacente.
Registra el resultado de establish(), las rutas, los paquetes excluidos y el motivo del apagado. Esas cuatro líneas separan un problema de consentimiento de un problema de alcance y de un problema de ciclo de vida sin capturar paquetes.