Android VPN: descritor do VpnService contra um dispositivo tun de root
Os apps Android não conseguem criar tun0 sozinhos: o que establish() devolve, onde o Binder para e o JNI começa, e quando um dispositivo criado por root vence.
Dois caminhos podem colocar uma interface IP na frente de um aplicativo Android, e ambos terminam no mesmo tipo de descritor de arquivo com proprietários muito diferentes. Um passa pelo VpnService e seu builder, o outro por um shell root e /dev/net/tun. Cada leitura nesse descritor é um pacote de saída e cada escrita é um pacote de entrada. O que separa os caminhos é quem detém a política, quem sobrevive a uma atualização do sistema e quanto da tabela de rotas você pode tocar.
Siga o caminho da plataforma, a menos que consiga nomear a coisa específica que ele se recusa a fazer. Essa recusa costuma ser TAP, captura fora do seu próprio conjunto de apps, ou uma regra de roteamento que o framework nunca expõe.
No caminho da plataforma você declara intenção em vez de comandos. Você fornece pelo menos um endereço, as rotas desejadas, servidores DNS e uma lista opcional de apps, e então o serviço do sistema do Android executa a configuração privilegiada sob sua própria identidade. Você nunca sabe o nome da interface de antemão e nunca emite o ioctl que a cria, e é exatamente esse o ponto.
Duas formas de obter uma interface IP
O caminho root é a receita mais antiga. Você abre /dev/net/tun, chama TUNSETIFF com IFF_TUN, sobe a interface com ip link e adiciona endereços e rotas à mão. Cada passo é seu para acertar, inclusive os que o framework teria resolvido em silêncio. Uma rota faltando produz o mesmo relato ao usuário que um servidor morto.
A depuração difere tanto quanto a configuração. No caminho root a interface existe ou o seu ioctl devolveu um erro que você pode ler diretamente. No caminho da plataforma a interface só existe depois que establish() tem sucesso, então uma falha chega como exceção ou como descritor null em vez de um nó de dispositivo onde você possa fuçar.
O que establish() realmente devolve
Builder.establish() tem três desfechos e eles exigem três respostas diferentes. Um descritor significa que a interface está configurada e é sua para usar, null significa que a solicitação não passou, e uma exceção lançada significa que o serviço do sistema a rejeitou. Colapsar null e sucesso em um único booleano é a forma mais comum de um app relatar um túnel que funciona e que nunca carrega um pacote. A referência do Builder indica o requisito de endereço, e vale a pena lê-la antes de culpar o servidor.
As direções são fixadas pelo descritor, não pelo seu protocolo. As leituras dão pacotes de saída já selecionados para a VPN, e as escritas injetam pacotes de entrada de volta na interface. O seu próprio transporte cifra, autentica e enquadra esses pacotes, porque a plataforma para na fronteira do pacote.
Binder é um salto, JNI é outro eixo
Uma solicitação do builder atravessa o sistema pelo Binder, e em algum ponto abaixo disso a plataforma chama código nativo para configurar o dispositivo. São dois mecanismos diferentes empilhados numa única chamada. Binder é comunicação entre processos entre o seu app e um serviço do sistema, enquanto JNI é a ponte de Java para nativo dentro de um processo. Ler uma árvore AOSP do Android 9 faz a pilha parecer literal, e vale lembrar que nomes internos de classe e caminhos de arquivo mudam entre versões.
O que não muda é o contrato com que você pode contar: o builder, o descritor que ele devolve e o ciclo de vida do serviço ao redor. Nenhum app deveria chamar uma transação interna do serviço nem recriar um nó de dispositivo para obter uma interface. Se o seu código depende de um nome de classe do AOSP, ele vai quebrar numa versão que você não testou.
TUN carrega IP, TAP carrega Ethernet
TUN é uma interface IP. TAP é uma interface Ethernet, e essa diferença decide se a ponte é sequer possível. Um dispositivo TUN não tem endereço MAC nem cabeçalho Ethernet a encaminhar, então um cliente TUN não pode entrar numa ponte de camada 2, por mais que se escrevam as rotas.
O VpnService dá a variante IP, que é o que uma VPN em nível de app precisa e o que ela pode suportar sem uma pilha Ethernet crua. Se o seu desenho exige ponte, o caminho da plataforma não vai levá-lo até lá, e o caminho root só vai se o kernel e as permissões do dispositivo cooperarem.
Onde o caminho root ainda ganha
Três tarefas empurram as pessoas para fora do caminho da plataforma. A ponte de camada 2 precisa de TAP. Capturar tráfego de apps fora do seu próprio escopo de VPN precisa de visibilidade que o framework não concede. O roteamento por política que o builder nunca expõe, como uma tabela separada para um destino, precisa de regras que você instala sozinho.
Os custos chegam depois: um bootloader desbloqueado ou uma imagem com root, uma política SELinux que se desloca sob você e um dispositivo que deixa de se comportar corretamente após uma OTA. Uma receita com root também não pode ser distribuída pela distribuição normal de apps, então a maioria das equipes a mantém como caminho de diagnóstico interno em vez de produto.
Escolher entre os dois
Comece pela interface de que você precisa, não pelo código que tem. Se a resposta é um dispositivo TUN com rotas limitadas ao app, o caminho da plataforma é mais curto e sobrevive a atualizações. Se a resposta envolve quadros Ethernet, captura de outros apps ou regras de roteamento que o builder rejeita, você está em território root e deve orçá-lo como custo operacional.
Uma verificação que resolve a maioria das discussões: construa primeiro o caminho da plataforma, registre o resultado de establish() separado do estado da sessão remota e confirme um pacote em cada direção. Só então decida se a capacidade que falta justifica deixar o caminho suportado.