You need the Android SDK (Android Studio) and the JDK 8 (openjdk-8-jdk
).
You also need the Rust environment to build the Rust version:
wget https://sh.rustup.rs -O rustup-init
sh rustup-init
If gradle
is installed on your computer:
gradle build
Otherwise, you can call the gradle wrapper:
./gradlew build
This will build the Android application, the Java and Rust relay servers, both in debug and release versions.
Several gradle tasks are exposed in the root project. For instance:
debugJava
andreleaseJava
build the Android application and the Java relay server;debugRust
andreleaseRust
build the Android application and the Rust relay server.
Even if the Rust build tasks are exposed through gradle
(which wraps calls to
cargo
), it is often more convenient to use cargo
directly.
For instance, to build a release version of the Rust relay server:
cd relay-rust
cargo build --release
It will generate the binary in target/release/gnirehtet
.
To build gnirehtet.exe
from Linux, install the cross-compile toolchain (on
Debian):
sudo apt install gcc-mingw-w64-x86-64
rustup target add x86_64-pc-windows-gnu
Add the following lines to ~/.cargo/config
:
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-gcc-ar"
Then build:
cargo build --release --target=x86_64-pc-windows-gnu
It will generate target/x86_64-pc-windows-gnu/release/gnirehtet.exe
.
To import the project in Android Studio: File → Import…
From there, you can develop on the Android application and the Java relay server. You can also execute any gradle tasks, and run the tests with visual results.
The client registers itself as a VPN, in order to intercept the whole device network traffic.
It exchanges raw IPv4 packets as byte[]
with the device:
- it receives packets from the Android applications or system;
- it must forge response packets.
The client (executed on the Android device) just maintains a TCP connection to the relay server, and sends the raw packets to it.
This TCP connection is established over adb, after we started a reverse port redirection:
adb reverse localabstract:gnirehtet tcp:31416
This means that every connection initiated to localhost:31416
from the device
will be redirected to the port 31416
on the computer, on which the relay
server is listening.
The relay server does all the hard work. It receives the IP packets from every connected client and opens standard sockets (which, of course, don't require root) accordingly, then relays data in both directions. This requires to translate packets between level 3 (on the device side) and level 5 (on the network side) in the OSI model.
In a sense, the relay server behaves like a NAT (more precisely a port-restricted cone NAT), in that it opens connections on behalf of private peers. However, it differs from a standard NAT in the way it communicates with the clients (the private peers), by using a very specific (though simple) protocol, over a TCP connection.
The client is an Android project located in app/
.
The VpnService
is implemented by GnirehtetService
.
We control the application through intents to GnirehtetActivity
.
Some configuration options may be passed as extra parameters, converted to a
VpnConfiguration
instance. Currently, the user can configure the DNS servers
to use.
The very first time, Android requests to the user the permission to enable the
VPN. In that case, the API requires to call
startActivityForResult
, so we need an Activity
: this is the purpose
of AuthorizationActivity
.
RelayTunnel
manages one connection to the relay server.
PersistentRelayTunnel
manages RelayTunnel
instances to handle
reconnections, so that we can stop and start the relay while the client keeps
running.
To send response packets to the system, we must write one packet at a time to
the VPN interface. Since we receive packets from the relay server over a TCP
connection, we have to split writes at packet boundaries: this is the purpose
of IPPacketOutputStream
.
The relay server comes in two flavors:
- the Java version is a Java 8 project located in
relay-java/
; - the Rust version is a Rust project located in
relay-rust/
.
It is implemented using asynchronous I/O (through Java NIO and Rust mio). As a consequence, it is essentially monothreaded, so there is no need for synchronization to handle packets.
There are different socket channels registered to a unique selector:
- one for the server socket, listening on port 31416;
- one for each client, accepted by the server socket;
- one for each TCP connection to the network;
- one for each UDP connection to the network.
Initially, only the server socket channel is registered.
In Java, the channels (SelectableChannel
) are
registered to the selector (Selector
) defined in
Relay
, with their SelectionHandler
as
attachment (for better decoupling). A Client
is created for every accepted client.
In Rust, our own Selector
class wraps the
Poll
from mio to expose an API accepting event handlers instead
of low-level tokens. The selector instance is created in
Relay
. The channels are called "handles" in mio; they are
simply the socket instances themselves (TcpListener
,
TcpStream
and UdpSocket
). A
Client
is created for every accepted client.
Each client manages a TCP socket, used to transmit raw IP packets from and to the Gnirehtet Android client. Thus, these IP packets are encapsulated into TCP (they are transmitted as the TCP payload).
When a client connects, the relay server assigns an integer id to it, which it
writes to the TCP socket. The client considers itself connected to the relay
server only once it has received this number. This allows to detect any
end-to-end connection issue immediately. For instance, a TCP connect initiated
by a client succeeds whenever a port redirection is enabled (typically through
adb reverse
), even if the relay server is not listening. In that case, the
first read will fail.
A class representing an IPv4 packet
(IPv4Packet
| Ipv4Packet
) provides a
structured view to read and write packet data, which is physically stored in the
buffers (the little squares on the schema). Since we handle one packet at a time
with asynchronous I/O, there is no need to copy or synchronize access to the
packets data: the packets just point to the buffer where they are stored.
Each packet contains an instance of IPv4 headers and transport headers (which might be TCP or UDP headers).
In Java, this is straightforward: IPv4Header
,
TCPHeader
and UDPHeader
just share a
slice of the raw packet buffer.
In Rust, the borrowing rules prevent to share a mutable reference.
Therefore, header data classes (*HeaderData
) are used to store the fields,
and lifetime-bound views (*Header<'a>
and *HeaderMut<'a>
) reference both
the raw array and the header data:
ipv4_header
:- data:
Ipv4HeaderData
- view:
Ipv4Header<'a>
- mutable view:
Ipv4HeaderMut<'a>
- data:
tcp_header
:- data:
TcpHeaderData
- view:
TcpHeader<'a>
- mutable view:
TcpHeaderMut<'a>
- data:
udp_header
:- data:
UdpHeaderData
- view:
UdpHeader<'a>
- mutable view:
UdpHeaderMut<'a>
- data:
In addition, we use enums for transport headers to statically dispatch calls to UDP and TCP header classes:
transport_header
:- data:
TransportHeaderData
- view:
TransportHeader<'a>
- mutable view:
TransportHeaderMut<'a>
- data:
Each client holds a router
(Router
| Router
), responsible for sending the
packets to the right connection, identified by these 5 properties available in
the IP and transport headers:
- protocol
- source address
- source port
- destination address
- destination port
These identifiers are stored in a connection id
(ConnectionId
| ConnectionId
),
used as a key to find or create the associated connection.
A connection (Connection
|
Connection
) is either a TCP connection
(TCPConnection
| TcpConnection
)
or a UDP connection (UDPConnection
|
UdpConnection
) to the requested destination. It
registers its own channel to the selector.
The connection is responsible for converting data from level 3 to level 5 for device-to-network packets, and from level 5 to level 3 for network-to-device packets. For UDP connections, it consists essentially in removing or adding IP and transport headers. For TCP connections, however, it requires to respond to the client according to the TCP protocol (RFC 793), in such a way as to ensure a correct end-to-end communication.
A packetizer (Packetizer
|
Packetizer
) converts from level 5 to level 3 by appending
correct IP and transport headers.
When the first packet for a specific UDP connection is received from the device,
a new UdpConnection
is created. It keeps a copy of the IP and UDP headers
of this first packet, swapping the source and the destination, in order to use
them as headers for all response packets.
The relaying is simple for UDP: each packet received from one side must be sent to the other side, without any splitting or merging (datagram boundaries must be preserved for UDP).
Since UDP is not a connected protocol, a UDP connection is never "closed". Therefore, the selector wakes up once per minute (using a timeout) to clean expired (in practice, unused for more than 2 minutes) UDP connections.
TcpConnection
also keeps, as a reference, a copy of the IP and TCP headers
of the first packet received.
However, contrary to UDP, TCP must provide reliable delivery. In particular, lost packets have to be retransmitted. Nonetheless, we can take advantage of the two TCP we are proxifying, so that we can provide reliability by delegating the retransmission mechanism to them. In fact, it is sufficient to guarantee that we cannot lose packets from network to device.
Indeed, any packet written to a TCP channel is safe, since it will be managed by the TCP implementation from the system. Losing a raw IP packet received from the device is also safe: the device TCP implementation will follow the TCP protocol to retransmit it. Therefore, dropping packets from device to network does not break the connection.
On the other hand, once we retrieved a packet from a TCP channel from the network, we are responsible for it. Would it be dropped, there would be no way to recover the connection.
As far as I know, there are only two possible causes of packet loss for which we are responsible:
-
When our buffers are full, we won't resize them indefinitely, so we have to drop packets. Typically, this may happen if the data from the network is received at a higher rate than that they can be sent to the device.
-
When a raw packet is considered invalid by the device, it is rejected. This may happen for example if the checksum is invalid or if the TCP sequence number is out-of-the-window.
Therefore, by contraposition, if we guarantee that we never retrieve a packet that we won't be able to store, and that we provide a valid checksum and respect the client TCP window, then we won't lose any packet without implementing any retransmission mechanism.
To prevent retrieving a packet while our buffers are full, we indicate that we
are not interested in reading (interestOps
|
interest
) the TCP channel when some pending data remain to
be written to the client buffer. Once some space becomes available, the client
then pulls the available packets from the TcpConnection
s, which are packet
sources (PacketSource
|
PacketSource
).
For more details, go read the code!
If you find a bug, or have an awesome idea to implement, please discuss and contribute ;-)