365 lines
14 KiB
Java
365 lines
14 KiB
Java
/*
|
|
* Copyright (C) 2017 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.example.android.toyvpn;
|
|
|
|
import static java.nio.charset.StandardCharsets.US_ASCII;
|
|
|
|
import android.app.PendingIntent;
|
|
import android.content.pm.PackageManager;
|
|
import android.net.ProxyInfo;
|
|
import android.net.VpnService;
|
|
import android.os.ParcelFileDescriptor;
|
|
import android.text.TextUtils;
|
|
import android.util.Log;
|
|
|
|
import java.io.FileInputStream;
|
|
import java.io.FileOutputStream;
|
|
import java.io.IOException;
|
|
import java.net.InetSocketAddress;
|
|
import java.net.SocketAddress;
|
|
import java.net.SocketException;
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.channels.DatagramChannel;
|
|
import java.util.Set;
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
public class ToyVpnConnection implements Runnable {
|
|
/**
|
|
* Callback interface to let the {@link ToyVpnService} know about new connections
|
|
* and update the foreground notification with connection status.
|
|
*/
|
|
public interface OnEstablishListener {
|
|
void onEstablish(ParcelFileDescriptor tunInterface);
|
|
}
|
|
|
|
/** Maximum packet size is constrained by the MTU, which is given as a signed short. */
|
|
private static final int MAX_PACKET_SIZE = Short.MAX_VALUE;
|
|
|
|
/** Time to wait in between losing the connection and retrying. */
|
|
private static final long RECONNECT_WAIT_MS = TimeUnit.SECONDS.toMillis(3);
|
|
|
|
/** Time between keepalives if there is no traffic at the moment.
|
|
*
|
|
* TODO: don't do this; it's much better to let the connection die and then reconnect when
|
|
* necessary instead of keeping the network hardware up for hours on end in between.
|
|
**/
|
|
private static final long KEEPALIVE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(15);
|
|
|
|
/** Time to wait without receiving any response before assuming the server is gone. */
|
|
private static final long RECEIVE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(20);
|
|
|
|
/**
|
|
* Time between polling the VPN interface for new traffic, since it's non-blocking.
|
|
*
|
|
* TODO: really don't do this; a blocking read on another thread is much cleaner.
|
|
*/
|
|
private static final long IDLE_INTERVAL_MS = TimeUnit.MILLISECONDS.toMillis(100);
|
|
|
|
/**
|
|
* Number of periods of length {@IDLE_INTERVAL_MS} to wait before declaring the handshake a
|
|
* complete and abject failure.
|
|
*
|
|
* TODO: use a higher-level protocol; hand-rolling is a fun but pointless exercise.
|
|
*/
|
|
private static final int MAX_HANDSHAKE_ATTEMPTS = 50;
|
|
|
|
private final VpnService mService;
|
|
private final int mConnectionId;
|
|
|
|
private final String mServerName;
|
|
private final int mServerPort;
|
|
private final byte[] mSharedSecret;
|
|
|
|
private PendingIntent mConfigureIntent;
|
|
private OnEstablishListener mOnEstablishListener;
|
|
|
|
// Proxy settings
|
|
private String mProxyHostName;
|
|
private int mProxyHostPort;
|
|
|
|
// Allowed/Disallowed packages for VPN usage
|
|
private final boolean mAllow;
|
|
private final Set<String> mPackages;
|
|
|
|
public ToyVpnConnection(final VpnService service, final int connectionId,
|
|
final String serverName, final int serverPort, final byte[] sharedSecret,
|
|
final String proxyHostName, final int proxyHostPort, boolean allow,
|
|
final Set<String> packages) {
|
|
mService = service;
|
|
mConnectionId = connectionId;
|
|
|
|
mServerName = serverName;
|
|
mServerPort= serverPort;
|
|
mSharedSecret = sharedSecret;
|
|
|
|
if (!TextUtils.isEmpty(proxyHostName)) {
|
|
mProxyHostName = proxyHostName;
|
|
}
|
|
if (proxyHostPort > 0) {
|
|
// The port value is always an integer due to the configured inputType.
|
|
mProxyHostPort = proxyHostPort;
|
|
}
|
|
mAllow = allow;
|
|
mPackages = packages;
|
|
}
|
|
|
|
/**
|
|
* Optionally, set an intent to configure the VPN. This is {@code null} by default.
|
|
*/
|
|
public void setConfigureIntent(PendingIntent intent) {
|
|
mConfigureIntent = intent;
|
|
}
|
|
|
|
public void setOnEstablishListener(OnEstablishListener listener) {
|
|
mOnEstablishListener = listener;
|
|
}
|
|
|
|
@Override
|
|
public void run() {
|
|
try {
|
|
Log.i(getTag(), "Starting");
|
|
|
|
// If anything needs to be obtained using the network, get it now.
|
|
// This greatly reduces the complexity of seamless handover, which
|
|
// tries to recreate the tunnel without shutting down everything.
|
|
// In this demo, all we need to know is the server address.
|
|
final SocketAddress serverAddress = new InetSocketAddress(mServerName, mServerPort);
|
|
|
|
// We try to create the tunnel several times.
|
|
// TODO: The better way is to work with ConnectivityManager, trying only when the
|
|
// network is available.
|
|
// Here we just use a counter to keep things simple.
|
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
|
// Reset the counter if we were connected.
|
|
if (run(serverAddress)) {
|
|
attempt = 0;
|
|
}
|
|
|
|
// Sleep for a while. This also checks if we got interrupted.
|
|
Thread.sleep(3000);
|
|
}
|
|
Log.i(getTag(), "Giving up");
|
|
} catch (IOException | InterruptedException | IllegalArgumentException e) {
|
|
Log.e(getTag(), "Connection failed, exiting", e);
|
|
}
|
|
}
|
|
|
|
private boolean run(SocketAddress server)
|
|
throws IOException, InterruptedException, IllegalArgumentException {
|
|
ParcelFileDescriptor iface = null;
|
|
boolean connected = false;
|
|
// Create a DatagramChannel as the VPN tunnel.
|
|
try (DatagramChannel tunnel = DatagramChannel.open()) {
|
|
|
|
// Protect the tunnel before connecting to avoid loopback.
|
|
if (!mService.protect(tunnel.socket())) {
|
|
throw new IllegalStateException("Cannot protect the tunnel");
|
|
}
|
|
|
|
// Connect to the server.
|
|
tunnel.connect(server);
|
|
|
|
// For simplicity, we use the same thread for both reading and
|
|
// writing. Here we put the tunnel into non-blocking mode.
|
|
tunnel.configureBlocking(false);
|
|
|
|
// Authenticate and configure the virtual network interface.
|
|
iface = handshake(tunnel);
|
|
|
|
// Now we are connected. Set the flag.
|
|
connected = true;
|
|
|
|
// Packets to be sent are queued in this input stream.
|
|
FileInputStream in = new FileInputStream(iface.getFileDescriptor());
|
|
|
|
// Packets received need to be written to this output stream.
|
|
FileOutputStream out = new FileOutputStream(iface.getFileDescriptor());
|
|
|
|
// Allocate the buffer for a single packet.
|
|
ByteBuffer packet = ByteBuffer.allocate(MAX_PACKET_SIZE);
|
|
|
|
// Timeouts:
|
|
// - when data has not been sent in a while, send empty keepalive messages.
|
|
// - when data has not been received in a while, assume the connection is broken.
|
|
long lastSendTime = System.currentTimeMillis();
|
|
long lastReceiveTime = System.currentTimeMillis();
|
|
|
|
// We keep forwarding packets till something goes wrong.
|
|
while (true) {
|
|
// Assume that we did not make any progress in this iteration.
|
|
boolean idle = true;
|
|
|
|
// Read the outgoing packet from the input stream.
|
|
int length = in.read(packet.array());
|
|
if (length > 0) {
|
|
// Write the outgoing packet to the tunnel.
|
|
packet.limit(length);
|
|
tunnel.write(packet);
|
|
packet.clear();
|
|
|
|
// There might be more outgoing packets.
|
|
idle = false;
|
|
lastReceiveTime = System.currentTimeMillis();
|
|
}
|
|
|
|
// Read the incoming packet from the tunnel.
|
|
length = tunnel.read(packet);
|
|
if (length > 0) {
|
|
// Ignore control messages, which start with zero.
|
|
if (packet.get(0) != 0) {
|
|
// Write the incoming packet to the output stream.
|
|
out.write(packet.array(), 0, length);
|
|
}
|
|
packet.clear();
|
|
|
|
// There might be more incoming packets.
|
|
idle = false;
|
|
lastSendTime = System.currentTimeMillis();
|
|
}
|
|
|
|
// If we are idle or waiting for the network, sleep for a
|
|
// fraction of time to avoid busy looping.
|
|
if (idle) {
|
|
Thread.sleep(IDLE_INTERVAL_MS);
|
|
final long timeNow = System.currentTimeMillis();
|
|
|
|
if (lastSendTime + KEEPALIVE_INTERVAL_MS <= timeNow) {
|
|
// We are receiving for a long time but not sending.
|
|
// Send empty control messages.
|
|
packet.put((byte) 0).limit(1);
|
|
for (int i = 0; i < 3; ++i) {
|
|
packet.position(0);
|
|
tunnel.write(packet);
|
|
}
|
|
packet.clear();
|
|
lastSendTime = timeNow;
|
|
} else if (lastReceiveTime + RECEIVE_TIMEOUT_MS <= timeNow) {
|
|
// We are sending for a long time but not receiving.
|
|
throw new IllegalStateException("Timed out");
|
|
}
|
|
}
|
|
}
|
|
} catch (SocketException e) {
|
|
Log.e(getTag(), "Cannot use socket", e);
|
|
} finally {
|
|
if (iface != null) {
|
|
try {
|
|
iface.close();
|
|
} catch (IOException e) {
|
|
Log.e(getTag(), "Unable to close interface", e);
|
|
}
|
|
}
|
|
}
|
|
return connected;
|
|
}
|
|
|
|
private ParcelFileDescriptor handshake(DatagramChannel tunnel)
|
|
throws IOException, InterruptedException {
|
|
// To build a secured tunnel, we should perform mutual authentication
|
|
// and exchange session keys for encryption. To keep things simple in
|
|
// this demo, we just send the shared secret in plaintext and wait
|
|
// for the server to send the parameters.
|
|
|
|
// Allocate the buffer for handshaking. We have a hardcoded maximum
|
|
// handshake size of 1024 bytes, which should be enough for demo
|
|
// purposes.
|
|
ByteBuffer packet = ByteBuffer.allocate(1024);
|
|
|
|
// Control messages always start with zero.
|
|
packet.put((byte) 0).put(mSharedSecret).flip();
|
|
|
|
// Send the secret several times in case of packet loss.
|
|
for (int i = 0; i < 3; ++i) {
|
|
packet.position(0);
|
|
tunnel.write(packet);
|
|
}
|
|
packet.clear();
|
|
|
|
// Wait for the parameters within a limited time.
|
|
for (int i = 0; i < MAX_HANDSHAKE_ATTEMPTS; ++i) {
|
|
Thread.sleep(IDLE_INTERVAL_MS);
|
|
|
|
// Normally we should not receive random packets. Check that the first
|
|
// byte is 0 as expected.
|
|
int length = tunnel.read(packet);
|
|
if (length > 0 && packet.get(0) == 0) {
|
|
return configure(new String(packet.array(), 1, length - 1, US_ASCII).trim());
|
|
}
|
|
}
|
|
throw new IOException("Timed out");
|
|
}
|
|
|
|
private ParcelFileDescriptor configure(String parameters) throws IllegalArgumentException {
|
|
// Configure a builder while parsing the parameters.
|
|
VpnService.Builder builder = mService.new Builder();
|
|
for (String parameter : parameters.split(" ")) {
|
|
String[] fields = parameter.split(",");
|
|
try {
|
|
switch (fields[0].charAt(0)) {
|
|
case 'm':
|
|
builder.setMtu(Short.parseShort(fields[1]));
|
|
break;
|
|
case 'a':
|
|
builder.addAddress(fields[1], Integer.parseInt(fields[2]));
|
|
break;
|
|
case 'r':
|
|
builder.addRoute(fields[1], Integer.parseInt(fields[2]));
|
|
break;
|
|
case 'd':
|
|
builder.addDnsServer(fields[1]);
|
|
break;
|
|
case 's':
|
|
builder.addSearchDomain(fields[1]);
|
|
break;
|
|
}
|
|
} catch (NumberFormatException e) {
|
|
throw new IllegalArgumentException("Bad parameter: " + parameter);
|
|
}
|
|
}
|
|
|
|
// Create a new interface using the builder and save the parameters.
|
|
final ParcelFileDescriptor vpnInterface;
|
|
for (String packageName : mPackages) {
|
|
try {
|
|
if (mAllow) {
|
|
builder.addAllowedApplication(packageName);
|
|
} else {
|
|
builder.addDisallowedApplication(packageName);
|
|
}
|
|
} catch (PackageManager.NameNotFoundException e){
|
|
Log.w(getTag(), "Package not available: " + packageName, e);
|
|
}
|
|
}
|
|
builder.setSession(mServerName).setConfigureIntent(mConfigureIntent);
|
|
if (!TextUtils.isEmpty(mProxyHostName)) {
|
|
builder.setHttpProxy(ProxyInfo.buildDirectProxy(mProxyHostName, mProxyHostPort));
|
|
}
|
|
synchronized (mService) {
|
|
vpnInterface = builder.establish();
|
|
if (mOnEstablishListener != null) {
|
|
mOnEstablishListener.onEstablish(vpnInterface);
|
|
}
|
|
}
|
|
Log.i(getTag(), "New interface: " + vpnInterface + " (" + parameters + ")");
|
|
return vpnInterface;
|
|
}
|
|
|
|
private final String getTag() {
|
|
return ToyVpnConnection.class.getSimpleName() + "[" + mConnectionId + "]";
|
|
}
|
|
}
|