Quattro supposizioni su Android VpnService mi sono costate una volta gran parte di una settimana, e nessuna riguardava il protocollo di rete. L'app diceva connesso, l'icona della chiave sedeva nella barra di stato, e il traffico usciva comunque dalla rotta normale. Continuavo a dare per scontato che il tunnel fosse mio finché non lo fermavo, che le rotte che aggiungevo fossero le rotte che ottenevo, e che chiamare stopSelf avrebbe portato giù l'interfaccia con sé. Il servizio si comportava esattamente come documentato. La parte rotta era il mio modello mentale.

Mito 1: il tunnel resta su finché non lo fermi

Android tiene un'interfaccia VPN alla volta. Creane una seconda e la piattaforma non la mette in coda: disattiva la tua e chiama onRevoke() sul tuo servizio. Se il tuo codice smonta tutto solo alla pressione di un pulsante, una presa di controllo lascia dietro un descrittore e un thread di lettura, e l'avvio successivo eredita entrambi.

La documentazione di VpnService dice esplicitamente che prepare() restituisce null quando l'app ha già il consenso e un Intent quando l'utente deve ancora approvare. Quel consenso arriva solo se consegni l'intent tramite startActivityForResult(). Salta la consegna e establish() restituisce null, che si legge come un fallimento silenzioso e non come un problema di permessi.

Always-on VPN aggiunge un proprietario che non controlli. Il sistema può riportare su il tunnel dopo un avvio o dopo un crash, e anticipa un'app VPN avviata a mano dall'utente. Costruisci onRevoke() in modo che chiuda le stesse risorse del tuo spegnimento normale, e trattalo come via di arresto principale e non come caso limite. Gli utenti cambiano app VPN; il tuo codice deve sopravvivere.

Mito 2: addRoute cattura esattamente ciò che hai elencato

Le rotte decidono quali pacchetti raggiungono il tuo descrittore, e un errore di portata produce il caso esasperante in cui parte del traffico passa nel tunnel e parte no. addRoute("0.0.0.0", 0) prende tutto l'IPv4, il che significa che ogni pacchetto ora arriva a te e va inoltrato o scartato. Un /32 per un solo host non prende quasi nulla, e tutto il resto si comporta come se il tuo VPN non fosse mai stato installato.

Le famiglie si contano separatamente. Una rotta solo IPv4 lascia IPv6 sulla rete sottostante, così un host dual-stack può ancora risolvere e connettersi fuori dal tunnel pur sembrando perfettamente connesso dentro.

Il DNS segue la stessa logica. I server aggiunti con addDnsServer() vanno alle app che usano il resolver del tunnel, mentre un'app con un proprio endpoint DNS cifrato o con Private DNS impostato su un nome host li ignora del tutto. Quando i nomi falliscono per un'app e funzionano per un'altra, controlla il percorso del resolver prima di toccare una sola rotta.

Mito 3: le regole per app sono un interruttore che puoi girare in entrambi i sensi

addDisallowedApplication() e addAllowedApplication() sono due modalità, non due filtri. Il builder accetta un modello per tunnel, quindi un'app che vuole tutto tranne tre pacchetti usa l'elenco dei vietati e lascia vuoto quello dei consentiti. I due si escludono a vicenda, e la gente lo scopre a runtime dopo che la schermata delle impostazioni ha già offerto entrambi gli interruttori.

La visibilità dei pacchetti morde anche. Su Android 11 e successivi puoi nominare solo i pacchetti che vedi, quindi un elenco di esclusioni costruito da una query che la tua app non può eseguire perderà voci in silenzio. Confronta l'elenco con ciò che il gestore dei pacchetti restituisce davvero prima di fidarti.

I profili di lavoro dividono di nuovo il pubblico. Un tunnel avviato dentro il profilo copre quel profilo e le sue app, mentre il lato personale mantiene la propria connettività. È una decisione di portata presa una volta, e appartiene all'interfaccia, non a un ticket di supporto.

Mito 4: stopSelf termina il tunnel

No. L'interfaccia vive nel descrittore restituito da establish(), e l'oggetto del servizio può morire mentre quel descrittore resta aperto. Chiudi prima il ParcelFileDescriptor, lascia cadere il riferimento, unisci o interrompi i thread di lettura e scrittura, poi chiama stopForeground(true) e stopSelf() in quest'ordine.

Un descrittore dimenticato è la ragione abituale per cui un servizio sembra sopravvivere alla propria richiesta di arresto, e spiega una rotta obsoleta che vive più a lungo dell'app che l'ha aggiunta.

L'errore inverso è altrettanto comune: chiudere il descrittore mentre un thread di lettura è a metà lettura, e poi lasciare che quel thread gestisca un'eccezione per cui nessuno ha scritto nulla. Tieni la sequenza di spegnimento in un solo posto e fai chiamare lì sia l'azione dell'utente sia onRevoke().

Cosa controllare quando il tunnel sembra morto

Proteggi i socket di lunga durata con protect() prima che il tunnel salga. Senza, il trasporto del tunnel stesso viene instradato di nuovo nel tunnel e la connessione si blocca in un modo che sembra esattamente un guasto del server. Prova contro un indirizzo che esiste solo dietro la rete remota, non contro un sito pubblico che risponde dalla cache o dal percorso sottostante.

Registra il risultato di establish(), le rotte, i pacchetti esclusi e il motivo dello spegnimento. Quelle quattro righe separano un problema di consenso da un problema di portata e da un problema di ciclo di vita senza catturare pacchetti.

Articoli correlati