Waarom je Android VpnService-tunnel stopt met het routeren van verkeer
Vier VpnService-aannames die in productie breken: één tunnel tegelijk, routereikwijdte, lijsten per app en de afsluitvolgorde, met per stuk een controle.
Vier aannames over Android VpnService kostten me ooit het grootste deel van een week, en geen ervan ging over het netwerkprotocol. De app zei verbonden, het sleutelicoon zat in de statusbalk, en het verkeer ging nog steeds over de normale route naar buiten. Ik bleef aannemen dat de tunnel van mij was tot ik hem stopte, dat de routes die ik toevoegde de routes waren die ik kreeg, en dat een aanroep van stopSelf de interface mee naar beneden zou nemen. De dienst gedroeg zich precies zoals gedocumenteerd. Mijn mentale model was het kapotte deel.
Mythe 1: de tunnel blijft overeind tot je hem stopt
Android houdt één VPN-interface tegelijk. Zet er een tweede op en het platform zet hem niet in de wachtrij: het deactiveert de jouwe en roept onRevoke() aan op je dienst. Als je code alleen bij een knopdruk afbreekt, laat een overname een descriptor en een leesthread achter, en de volgende start erft beide.
De VpnService-referentie zegt uitdrukkelijk dat prepare() null teruggeeft wanneer de app al toestemming heeft, en een Intent wanneer de gebruiker die nog moet goedkeuren. Die toestemming komt alleen aan als je de intent via startActivityForResult() aflevert. Sla de overdracht over en establish() geeft null terug, wat leest als een stille fout in plaats van een rechtenprobleem.
Always-on VPN voegt een eigenaar toe die je niet bestuurt. Het systeem kan de tunnel na een herstart of na een crash terugbrengen, en het gaat voor op een VPN-app die de gebruiker met de hand startte. Bouw onRevoke() zo dat het dezelfde bronnen sluit als je normale afsluiting, en behandel het als een primair stoppad in plaats van een randgeval. Gebruikers wisselen van VPN-app; je code moet dat overleven.
Mythe 2: addRoute vangt precies wat je hebt opgesomd
Routes bepalen welke pakketten je descriptor bereiken, en een fout in de reikwijdte levert het gekmakende geval op waarin een deel van het verkeer door de tunnel gaat en een deel niet. addRoute("0.0.0.0", 0) neemt heel IPv4, wat betekent dat elk pakket nu bij jou aankomt en moet worden doorgestuurd of weggegooid. Een /32 voor één host neemt bijna niets, en al het andere gedraagt zich alsof je VPN nooit is geïnstalleerd.
Families worden apart geteld. Een route met alleen IPv4 laat IPv6 op het onderliggende netwerk, dus een dual-stack-host kan nog steeds buiten de tunnel resolven en verbinden terwijl hij er binnenin perfect verbonden uitziet.
DNS volgt dezelfde logica. Servers die met addDnsServer() zijn toegevoegd, gaan naar apps die de resolver van de tunnel gebruiken, terwijl een app met een eigen versleuteld DNS-eindpunt of Private DNS ingesteld op een hostnaam ze volledig negeert. Als namen voor de ene app falen en voor de andere werken, controleer dan het resolverpad voordat je één route aanraakt.
Mythe 3: regels per app zijn een schakelaar die je beide kanten op kunt zetten
addDisallowedApplication() en addAllowedApplication() zijn twee modi, geen twee filters. De builder accepteert één model per tunnel, dus een app die alles behalve drie pakketten wil, gebruikt de verboden lijst en laat de toegestane lijst leeg. De twee sluiten elkaar uit, en mensen ontdekken dat tijdens runtime nadat het instellingenscherm al beide schakelaars heeft aangeboden.
Pakketzichtbaarheid bijt ook. Op Android 11 en later kun je alleen pakketten noemen die je kunt zien, dus een uitsluitingslijst die is gebouwd uit een query die je app niet mag uitvoeren, mist stilletjes vermeldingen. Vergelijk de lijst met wat de pakketbeheerder werkelijk teruggeeft voordat je erop vertrouwt.
Werkprofielen verdelen het publiek opnieuw. Een tunnel die in het profiel is gestart, dekt dat profiel en zijn apps, terwijl de persoonlijke kant zijn eigen connectiviteit houdt. Dat is een eenmalig genomen beslissing over de reikwijdte, en die hoort in de interface, niet in een supportticket.
Mythe 4: stopSelf beëindigt de tunnel
Dat doet het niet. De interface leeft in de descriptor die establish() teruggeeft, en het dienstobject kan sterven terwijl die descriptor open blijft. Sluit eerst de ParcelFileDescriptor, laat de referentie vallen, join of onderbreek de lees- en schrijfthreads, en roep dan stopForeground(true) en stopSelf() in die volgorde aan.
Een vergeten descriptor is de gebruikelijke reden dat een dienst zijn eigen stopverzoek lijkt te overleven, en het verklaart een verouderde route die langer leeft dan de app die hem toevoegde.
De omgekeerde fout is net zo gewoon: de descriptor sluiten terwijl een leesthread midden in een leesactie zit, en die thread dan een uitzondering laten afhandelen waarvoor niemand code schreef. Houd de afsluitvolgorde op één plek en laat zowel de gebruikersactie als onRevoke() daarin aanroepen.
Wat je moet controleren als de tunnel dood lijkt
Bescherm langlevende sockets met protect() voordat de tunnel omhoogkomt. Zonder dat wordt het eigen transport van de tunnel terug de tunnel in gerouteerd en blijft de verbinding hangen op een manier die er precies als een serverfout uitziet. Test tegen een adres dat alleen achter het externe netwerk bestaat, niet tegen een openbare site die uit de cache of vanaf het onderliggende pad antwoordt.
Log het resultaat van establish(), de routes, de uitgesloten pakketten en de reden van afsluiten. Die vier regels scheiden een toestemmingsprobleem van een reikwijdteprobleem en van een levenscyclusprobleem zonder packet capture.