Android VPN: VpnService-Deskriptor gegen ein Root-tun-Gerät
Android-Apps können tun0 nicht selbst anlegen: was establish() zurückgibt, wo Binder endet und JNI beginnt und wann ein von Root erzeugtes Gerät gewinnt.
Zwei Wege können eine IP-Schnittstelle vor eine Android-App setzen, und beide enden bei derselben Art Dateideskriptor mit sehr unterschiedlichen Eigentümern. Der eine läuft über VpnService und seinen Builder, der andere über eine Root-Shell und /dev/net/tun. Jeder Lesevorgang auf diesem Deskriptor ist ein ausgehendes Paket, jeder Schreibvorgang ein eingehendes. Was die Wege trennt, ist, wer die Policy hält, was ein OS-Upgrade übersteht und wie viel der Routing-Tabelle du anfassen darfst.
Nimm den Plattformweg, es sei denn, du kannst genau benennen, was er verweigert. Diese Verweigerung ist normalerweise TAP, Mitschneiden außerhalb deines eigenen App-Satzes oder eine Routing-Regel, die das Framework nie offenlegt.
Auf dem Plattformweg erklärst du Absicht statt Befehle. Du gibst mindestens eine Adresse, die gewünschten Routen, DNS-Server und eine optionale App-Liste an, dann führt Androids Systemdienst die privilegierte Einrichtung unter seiner eigenen Identität aus. Du erfährst den Schnittstellennamen nie vorher, und du gibst nie das ioctl aus, das sie erzeugt, und genau das ist der Punkt.
Zwei Wege zu einer IP-Schnittstelle
Der Root-Weg ist das ältere Rezept. Du öffnest /dev/net/tun, rufst TUNSETIFF mit IFF_TUN auf, bringst die Schnittstelle mit ip link hoch und trägst Adressen und Routen von Hand ein. Jeder Schritt liegt bei dir, auch die, die das Framework still erledigt hätte. Eine fehlende Route erzeugt denselben Nutzerbericht wie ein toter Server.
Das Debuggen unterscheidet sich genauso wie der Aufbau. Auf dem Root-Weg existiert die Schnittstelle entweder, oder dein ioctl hat einen Fehler zurückgegeben, den du direkt lesen kannst. Auf dem Plattformweg existiert die Schnittstelle erst, nachdem establish() erfolgreich war, deshalb kommt ein Fehlschlag als Ausnahme oder als null-Deskriptor an und nicht als Geräteknoten, in dem du stochern kannst.
Was establish() tatsächlich zurückgibt
Builder.establish() hat drei Ausgänge, und sie brauchen drei verschiedene Reaktionen. Ein Deskriptor bedeutet, die Schnittstelle ist konfiguriert und deine zum Benutzen, null bedeutet, die Anfrage ging nicht durch, und eine geworfene Ausnahme bedeutet, der Systemdienst hat sie abgelehnt. null und Erfolg in einem Boolean zusammenzufassen ist der häufigste Weg, wie eine App einen funktionierenden Tunnel meldet, der nie ein Paket trägt. Die Builder-Referenz nennt die Adressanforderung, und die lohnt sich zu lesen, bevor du dem Server die Schuld gibst.
Die Richtungen bestimmt der Deskriptor, nicht dein Protokoll. Lesevorgänge liefern ausgehende Pakete, die bereits für das VPN ausgewählt sind, und Schreibvorgänge spritzen eingehende Pakete zurück in die Schnittstelle. Dein eigener Transport verschlüsselt, authentifiziert und rahmt diese Pakete, weil die Plattform an der Paketgrenze endet.
Binder ist ein Sprung, JNI ist eine andere Achse
Eine Builder-Anfrage wechselt über Binder ins System, und irgendwo darunter ruft die Plattform nativen Code auf, um das Gerät zu konfigurieren. Das sind zwei verschiedene Mechanismen, gestapelt in einem Aufruf. Binder ist Interprozesskommunikation zwischen deiner App und einem Systemdienst, während JNI die Brücke von Java zu nativ innerhalb eines Prozesses ist. Ein Android-9-AOSP-Baum zu lesen lässt den Stapel wörtlich aussehen, und man sollte bedenken, dass interne Klassennamen und Dateipfade sich zwischen Releases verschieben.
Was sich nicht verschiebt, ist der Vertrag, auf den du dich verlassen darfst: der Builder, der Deskriptor, den er zurückgibt, und der Dienstlebenszyklus darum herum. Keine App sollte eine interne Diensttransaktion aufrufen oder einen Geräteknoten neu erzeugen, um eine Schnittstelle zu bekommen. Wenn dein Code von einem AOSP-Klassennamen abhängt, bricht er bei einem Release, das du nicht getestet hast.
TUN trägt IP, TAP trägt Ethernet
TUN ist eine IP-Schnittstelle. TAP ist eine Ethernet-Schnittstelle, und dieser Unterschied entscheidet, ob Bridging überhaupt möglich ist. Ein TUN-Gerät hat keine MAC-Adresse und keinen Ethernet-Header weiterzuleiten, also kann ein TUN-Client keiner Layer-2-Bridge beitreten, wie die Routen auch geschrieben sind.
VpnService gibt dir die IP-Variante, die ein VPN auf App-Ebene braucht und ohne rohen Ethernet-Stack unterstützen kann. Wenn dein Entwurf Bridging verlangt, bringt dich der Plattformweg nicht dorthin, und der Root-Weg nur dann, wenn Kernel und Geräteberechtigungen mitspielen.
Wo der Root-Weg noch gewinnt
Drei Aufgaben drängen Leute vom Plattformweg weg. Layer-2-Bridging braucht TAP. Verkehr von Apps außerhalb deines eigenen VPN-Bereichs mitzuschneiden braucht Sichtbarkeit, die das Framework nicht gewährt. Policy-Routing, das der Builder nie offenlegt, etwa eine eigene Tabelle für ein Ziel, braucht Regeln, die du selbst installierst.
Die Kosten kommen später: ein entsperrter Bootloader oder ein gerootetes Image, SELinux-Policy, die sich unter dir verschiebt, und ein Gerät, das sich nach einem OTA nicht mehr richtig verhält. Ein gerootetes Rezept lässt sich auch nicht über die normale App-Verteilung ausliefern, deshalb halten die meisten Teams es als internen Diagnoseweg statt als Produkt.
Die Wahl zwischen beiden
Fang bei der Schnittstelle an, die du brauchst, nicht bei dem Code, den du hast. Wenn die Antwort ein TUN-Gerät mit app-begrenzten Routen ist, ist der Plattformweg kürzer und übersteht Upgrades. Wenn die Antwort Ethernet-Frames, das Mitschneiden anderer Apps oder Routing-Regeln umfasst, die der Builder ablehnt, bist du im Root-Gebiet und solltest es als Betriebskosten einplanen.
Eine Prüfung, die die meisten Diskussionen beendet: baue zuerst den Plattformweg, protokolliere das establish()-Ergebnis getrennt vom Zustand der Remote-Sitzung und bestätige ein Paket in jede Richtung. Erst dann entscheide, ob die fehlende Fähigkeit es rechtfertigt, den unterstützten Weg zu verlassen.